]> git.proxmox.com Git - flutter/proxmox_login_manager.git/blame - lib/proxmox_login_form.dart
fix issue when 443 port is used
[flutter/proxmox_login_manager.git] / lib / proxmox_login_form.dart
CommitLineData
0e468546
TM
1import 'dart:io';
2
3import 'package:flutter/material.dart';
4import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
5 as proxclient;
6import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart';
7import 'package:proxmox_login_manager/proxmox_general_settings_form.dart';
8import 'package:proxmox_login_manager/proxmox_general_settings_model.dart';
9import 'package:proxmox_login_manager/proxmox_login_model.dart';
10import 'package:proxmox_login_manager/proxmox_tfa_form.dart';
1dfe0c76 11import 'package:proxmox_login_manager/extension.dart';
0e468546
TM
12
13class ProxmoxProgressModel {
14 bool inProgress;
15 String message;
16 ProxmoxProgressModel({
17 this.inProgress = false,
18 this.message = 'Loading...',
19 });
20}
21
22class 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
50class _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
157class 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
172class _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 });
59ab8956
TL
470 var host = _originController.text.trim();
471 var apiBaseUrl = Uri.https(host, '');
0e468546 472
59ab8956
TL
473 RegExp portRE = new RegExp(r":\d{1,5}$");
474
475 if (!portRE.hasMatch(host)) {
0e468546
TM
476 _originController.text += ':8006';
477 apiBaseUrl = apiBaseUrl.replace(port: 8006);
478 }
479
480 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
481 List<PveAccessDomainModel> response;
482 try {
483 response =
484 await proxclient.accessDomains(apiBaseUrl, settings.sslValidation);
485 } on proxclient.ProxmoxApiException catch (e) {
486 print(e);
487 showDialog(
488 context: context,
489 builder: (context) => ProxmoxApiErrorDialog(
490 exception: e,
491 ),
492 );
493 } catch (e, trace) {
494 print(e);
495 print(trace);
496 if (e.runtimeType == HandshakeException) {
497 showDialog(
498 context: context,
499 builder: (context) => ProxmoxCertificateErrorDialog(),
500 );
501 } else {
502 showDialog(
503 context: context,
504 builder: (context) => AlertDialog(
505 title: Text('Connection error'),
506 content: Text('Could not establish connection.'),
507 actions: [
508 FlatButton(
509 onPressed: () => Navigator.of(context).pop(),
510 child: Text('Close'),
511 ),
512 ],
513 ),
514 );
515 }
516 }
3752cb66
TM
517
518 response?.sort((a, b) => a.realm.compareTo(b.realm));
519
0e468546
TM
520 final selection = response?.singleWhere(
521 (e) => e.realm == widget.userModel?.realm,
522 orElse: () => response?.first,
523 );
3752cb66 524
0e468546
TM
525 setState(() {
526 _progressModel.inProgress = false;
527 _selectedDomain = selection;
528 });
3752cb66 529
0e468546
TM
530 return response;
531 }
532
533 @override
534 void dispose() {
535 _originController.dispose();
536 _usernameController.dispose();
537 _passwordController.dispose();
538 super.dispose();
539 }
540}
541
542class ProxmoxProgressOverlay extends StatelessWidget {
543 const ProxmoxProgressOverlay({
544 Key key,
545 @required this.message,
546 }) : super(key: key);
547
548 final String message;
549
550 @override
551 Widget build(BuildContext context) {
552 return Container(
553 decoration: new BoxDecoration(color: Colors.black.withOpacity(0.5)),
554 child: Center(
555 child: Column(
556 mainAxisAlignment: MainAxisAlignment.center,
557 children: [
558 Text(
559 message,
560 style: TextStyle(
561 color: Theme.of(context).accentColor,
562 fontSize: 20,
563 ),
564 ),
565 Padding(
566 padding: const EdgeInsets.only(top: 20.0),
567 child: CircularProgressIndicator(),
568 )
569 ],
570 ),
571 ),
572 );
573 }
574}
575
576class ProxmoxApiErrorDialog extends StatelessWidget {
577 final proxclient.ProxmoxApiException exception;
578
579 const ProxmoxApiErrorDialog({
580 Key key,
581 @required this.exception,
582 }) : super(key: key);
583
584 @override
585 Widget build(BuildContext context) {
586 return AlertDialog(
587 title: Text('API Error'),
588 content: SingleChildScrollView(
589 child: Text(exception.message),
590 ),
591 actions: [
592 FlatButton(
593 onPressed: () => Navigator.of(context).pop(),
594 child: Text('Close'),
595 ),
596 ],
597 );
598 }
599}
600
601class ProxmoxCertificateErrorDialog extends StatelessWidget {
602 const ProxmoxCertificateErrorDialog({
603 Key key,
604 }) : super(key: key);
605
606 @override
607 Widget build(BuildContext context) {
608 return AlertDialog(
609 title: Text('Certificate error'),
610 content: SingleChildScrollView(
611 child: Column(
612 crossAxisAlignment: CrossAxisAlignment.start,
613 children: [
614 Text('Your connection is not private.'),
615 Text(
616 'Note: Consider to disable SSL validation,'
617 ' if you use a self signed, not commonly trusted, certificate.',
618 style: Theme.of(context).textTheme.caption,
619 ),
620 ],
621 ),
622 ),
623 actions: [
624 FlatButton(
625 onPressed: () => Navigator.of(context).pop(),
626 child: Text('Close'),
627 ),
628 FlatButton(
629 onPressed: () => Navigator.of(context).pushReplacement(
630 MaterialPageRoute(
631 builder: (context) => ProxmoxGeneralSettingsForm())),
632 child: Text('Settings'),
633 )
634 ],
635 );
636 }
637}