]>
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'; | |
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 | final origin = Uri.https(_originController.text, ''); | |
354 | var client = await proxclient.authenticate( | |
355 | '${_usernameController.text}@${_selectedDomain.realm}', | |
356 | _passwordController.text, | |
357 | origin, | |
358 | settings.sslValidation); | |
359 | ||
360 | if (client.credentials.tfa) { | |
361 | client = await Navigator.of(context).push(MaterialPageRoute( | |
362 | builder: (context) => ProxmoxTfaForm( | |
363 | apiClient: client, | |
364 | ), | |
365 | )); | |
366 | } | |
367 | ||
368 | var loginStorage = await ProxmoxLoginStorage.fromLocalStorage(); | |
369 | ||
370 | if (widget.isCreate) { | |
371 | final newLogin = ProxmoxLoginModel((b) => b | |
372 | ..origin = origin | |
373 | ..username = _usernameController.text | |
374 | ..realm = _selectedDomain.realm | |
375 | ..productType = ProxmoxProductType.pve | |
376 | ..ticket = client.credentials.ticket); | |
377 | loginStorage = loginStorage.rebuild((b) => b..logins.add(newLogin)); | |
378 | } else { | |
379 | loginStorage = loginStorage.rebuild((b) => b | |
380 | ..logins.remove(widget.userModel) | |
381 | ..logins.add(widget.userModel | |
382 | .rebuild((b) => b..ticket = client.credentials.ticket))); | |
383 | } | |
384 | await loginStorage.saveToDisk(); | |
385 | ||
386 | Navigator.of(context).pop(client); | |
387 | } on proxclient.ProxmoxApiException catch (e) { | |
388 | print(e); | |
389 | showDialog( | |
390 | context: context, | |
391 | builder: (context) => ProxmoxApiErrorDialog( | |
392 | exception: e, | |
393 | ), | |
394 | ); | |
395 | } catch (e, trace) { | |
396 | print(e); | |
397 | print(trace); | |
398 | if (e.runtimeType == HandshakeException) { | |
399 | showDialog( | |
400 | context: context, | |
401 | builder: (context) => ProxmoxCertificateErrorDialog(), | |
402 | ); | |
403 | } | |
404 | } | |
405 | setState(() { | |
406 | _progressModel.inProgress = false; | |
407 | }); | |
408 | } | |
409 | ||
410 | Future<List<PveAccessDomainModel>> _getAccessDomains() async { | |
411 | setState(() { | |
412 | _progressModel | |
413 | ..inProgress = true | |
414 | ..message = 'Connection test...'; | |
415 | }); | |
416 | var apiBaseUrl = Uri.https(_originController.text, ''); | |
417 | ||
418 | if (!apiBaseUrl.hasPort) { | |
419 | _originController.text += ':8006'; | |
420 | apiBaseUrl = apiBaseUrl.replace(port: 8006); | |
421 | } | |
422 | ||
423 | final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage(); | |
424 | List<PveAccessDomainModel> response; | |
425 | try { | |
426 | response = | |
427 | await proxclient.accessDomains(apiBaseUrl, settings.sslValidation); | |
428 | } on proxclient.ProxmoxApiException catch (e) { | |
429 | print(e); | |
430 | showDialog( | |
431 | context: context, | |
432 | builder: (context) => ProxmoxApiErrorDialog( | |
433 | exception: e, | |
434 | ), | |
435 | ); | |
436 | } catch (e, trace) { | |
437 | print(e); | |
438 | print(trace); | |
439 | if (e.runtimeType == HandshakeException) { | |
440 | showDialog( | |
441 | context: context, | |
442 | builder: (context) => ProxmoxCertificateErrorDialog(), | |
443 | ); | |
444 | } else { | |
445 | showDialog( | |
446 | context: context, | |
447 | builder: (context) => AlertDialog( | |
448 | title: Text('Connection error'), | |
449 | content: Text('Could not establish connection.'), | |
450 | actions: [ | |
451 | FlatButton( | |
452 | onPressed: () => Navigator.of(context).pop(), | |
453 | child: Text('Close'), | |
454 | ), | |
455 | ], | |
456 | ), | |
457 | ); | |
458 | } | |
459 | } | |
460 | final selection = response?.singleWhere( | |
461 | (e) => e.realm == widget.userModel?.realm, | |
462 | orElse: () => response?.first, | |
463 | ); | |
464 | setState(() { | |
465 | _progressModel.inProgress = false; | |
466 | _selectedDomain = selection; | |
467 | }); | |
468 | return response; | |
469 | } | |
470 | ||
471 | @override | |
472 | void dispose() { | |
473 | _originController.dispose(); | |
474 | _usernameController.dispose(); | |
475 | _passwordController.dispose(); | |
476 | super.dispose(); | |
477 | } | |
478 | } | |
479 | ||
480 | class ProxmoxProgressOverlay extends StatelessWidget { | |
481 | const ProxmoxProgressOverlay({ | |
482 | Key key, | |
483 | @required this.message, | |
484 | }) : super(key: key); | |
485 | ||
486 | final String message; | |
487 | ||
488 | @override | |
489 | Widget build(BuildContext context) { | |
490 | return Container( | |
491 | decoration: new BoxDecoration(color: Colors.black.withOpacity(0.5)), | |
492 | child: Center( | |
493 | child: Column( | |
494 | mainAxisAlignment: MainAxisAlignment.center, | |
495 | children: [ | |
496 | Text( | |
497 | message, | |
498 | style: TextStyle( | |
499 | color: Theme.of(context).accentColor, | |
500 | fontSize: 20, | |
501 | ), | |
502 | ), | |
503 | Padding( | |
504 | padding: const EdgeInsets.only(top: 20.0), | |
505 | child: CircularProgressIndicator(), | |
506 | ) | |
507 | ], | |
508 | ), | |
509 | ), | |
510 | ); | |
511 | } | |
512 | } | |
513 | ||
514 | class ProxmoxApiErrorDialog extends StatelessWidget { | |
515 | final proxclient.ProxmoxApiException exception; | |
516 | ||
517 | const ProxmoxApiErrorDialog({ | |
518 | Key key, | |
519 | @required this.exception, | |
520 | }) : super(key: key); | |
521 | ||
522 | @override | |
523 | Widget build(BuildContext context) { | |
524 | return AlertDialog( | |
525 | title: Text('API Error'), | |
526 | content: SingleChildScrollView( | |
527 | child: Text(exception.message), | |
528 | ), | |
529 | actions: [ | |
530 | FlatButton( | |
531 | onPressed: () => Navigator.of(context).pop(), | |
532 | child: Text('Close'), | |
533 | ), | |
534 | ], | |
535 | ); | |
536 | } | |
537 | } | |
538 | ||
539 | class ProxmoxCertificateErrorDialog extends StatelessWidget { | |
540 | const ProxmoxCertificateErrorDialog({ | |
541 | Key key, | |
542 | }) : super(key: key); | |
543 | ||
544 | @override | |
545 | Widget build(BuildContext context) { | |
546 | return AlertDialog( | |
547 | title: Text('Certificate error'), | |
548 | content: SingleChildScrollView( | |
549 | child: Column( | |
550 | crossAxisAlignment: CrossAxisAlignment.start, | |
551 | children: [ | |
552 | Text('Your connection is not private.'), | |
553 | Text( | |
554 | 'Note: Consider to disable SSL validation,' | |
555 | ' if you use a self signed, not commonly trusted, certificate.', | |
556 | style: Theme.of(context).textTheme.caption, | |
557 | ), | |
558 | ], | |
559 | ), | |
560 | ), | |
561 | actions: [ | |
562 | FlatButton( | |
563 | onPressed: () => Navigator.of(context).pop(), | |
564 | child: Text('Close'), | |
565 | ), | |
566 | FlatButton( | |
567 | onPressed: () => Navigator.of(context).pushReplacement( | |
568 | MaterialPageRoute( | |
569 | builder: (context) => ProxmoxGeneralSettingsForm())), | |
570 | child: Text('Settings'), | |
571 | ) | |
572 | ], | |
573 | ); | |
574 | } | |
575 | } |