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