]> git.proxmox.com Git - flutter/proxmox_login_manager.git/blob - lib/proxmox_login_form.dart
login: fix login for saved ipv6 addresses
[flutter/proxmox_login_manager.git] / lib / proxmox_login_form.dart
1 import 'dart:io';
2 import 'dart:async';
3
4 import 'package:flutter/material.dart';
5 import 'package:collection/src/iterable_extensions.dart';
6 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
7 as proxclient;
8 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart';
9 import 'package:proxmox_login_manager/proxmox_general_settings_form.dart';
10 import 'package:proxmox_login_manager/proxmox_general_settings_model.dart';
11 import 'package:proxmox_login_manager/proxmox_login_model.dart';
12 import 'package:proxmox_login_manager/proxmox_tfa_form.dart';
13 import 'package:proxmox_login_manager/extension.dart';
14
15 class ProxmoxProgressModel {
16 bool inProgress = false;
17 String message = 'Loading...';
18 ProxmoxProgressModel({
19 this.inProgress = false,
20 this.message = 'Loading...',
21 });
22 }
23
24 // FIXME: copied from pve_flutter_frontend, re-use common set
25 class ProxmoxColors {
26 static final Color orange = Color(0xFFE57000);
27 static final Color supportGrey = Color(0xFFABBABA);
28 static final Color supportBlue = Color(0xFF00617F);
29 }
30
31 class ProxmoxLoginForm extends StatefulWidget {
32 final TextEditingController originController;
33 final FormFieldValidator<String> originValidator;
34 final TextEditingController usernameController;
35 final TextEditingController passwordController;
36 final List<PveAccessDomainModel?>? accessDomains;
37 final PveAccessDomainModel? selectedDomain;
38 final ValueChanged<PveAccessDomainModel?> onDomainChanged;
39 final Function? onPasswordSubmitted;
40 final Function? onOriginSubmitted;
41
42 const ProxmoxLoginForm({
43 Key? key,
44 required this.originController,
45 required this.usernameController,
46 required this.passwordController,
47 required this.accessDomains,
48 required this.originValidator,
49 this.selectedDomain,
50 required this.onDomainChanged,
51 this.onPasswordSubmitted,
52 this.onOriginSubmitted,
53 }) : super(key: key);
54
55 @override
56 _ProxmoxLoginFormState createState() => _ProxmoxLoginFormState();
57 }
58
59 class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
60 bool _obscure = true;
61 FocusNode? passwordFocusNode;
62
63 @override
64 Widget build(BuildContext context) {
65 if (widget.accessDomains == null) {
66 return TextFormField(
67 decoration: InputDecoration(
68 icon: Icon(Icons.vpn_lock),
69 labelText: 'Origin',
70 hintText: 'e.g. 192.168.1.2',
71 helperText: 'Protocol (https) and default port (8006) implied'),
72 controller: widget.originController,
73 validator: widget.originValidator,
74 onFieldSubmitted: (value) => widget.onOriginSubmitted!(),
75 );
76 }
77
78 return AutofillGroup(
79 child: Column(
80 mainAxisAlignment: MainAxisAlignment.center,
81 children: [
82 TextFormField(
83 decoration: InputDecoration(
84 icon: Icon(Icons.vpn_lock),
85 labelText: 'Origin',
86 ),
87 controller: widget.originController,
88 enabled: false,
89 ),
90 TextFormField(
91 decoration: InputDecoration(
92 icon: Icon(Icons.person),
93 labelText: 'Username',
94 ),
95 controller: widget.usernameController,
96 validator: (value) {
97 if (value!.isEmpty) {
98 return 'Please enter username';
99 }
100 return null;
101 },
102 autofillHints: [AutofillHints.username],
103 ),
104 DropdownButtonFormField(
105 decoration: InputDecoration(icon: Icon(Icons.domain)),
106 items: widget.accessDomains!
107 .map((e) => DropdownMenuItem(
108 child: ListTile(
109 title: Text(e!.realm),
110 subtitle: Text(e.comment ?? ''),
111 ),
112 value: e,
113 ))
114 .toList(),
115 onChanged: widget.onDomainChanged,
116 selectedItemBuilder: (context) =>
117 widget.accessDomains!.map((e) => Text(e!.realm)).toList(),
118 value: widget.selectedDomain,
119 ),
120 Stack(
121 children: [
122 TextFormField(
123 decoration: InputDecoration(
124 icon: Icon(Icons.lock),
125 labelText: 'Password',
126 ),
127 controller: widget.passwordController,
128 obscureText: _obscure,
129 autocorrect: false,
130 focusNode: passwordFocusNode,
131 validator: (value) {
132 if (value!.isEmpty) {
133 return 'Please enter password';
134 }
135 return null;
136 },
137 onFieldSubmitted: (value) => widget.onPasswordSubmitted!(),
138 autofillHints: [AutofillHints.password],
139 ),
140 Align(
141 alignment: Alignment.bottomRight,
142 child: IconButton(
143 constraints: BoxConstraints.tight(Size(58, 58)),
144 iconSize: 24,
145 icon:
146 Icon(_obscure ? Icons.visibility : Icons.visibility_off),
147 onPressed: () => setState(() {
148 _obscure = !_obscure;
149 }),
150 ),
151 )
152 ],
153 ),
154 ],
155 ),
156 );
157 }
158
159 @override
160 void dispose() {
161 passwordFocusNode?.dispose();
162 super.dispose();
163 }
164 }
165
166 class ProxmoxLoginPage extends StatefulWidget {
167 final ProxmoxLoginModel? userModel;
168 final bool? isCreate;
169 final String? ticket;
170
171 const ProxmoxLoginPage({
172 Key? key,
173 this.userModel,
174 this.isCreate,
175 this.ticket = '',
176 }) : super(key: key);
177 @override
178 _ProxmoxLoginPageState createState() => _ProxmoxLoginPageState();
179 }
180
181 class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
182 final _originController = TextEditingController();
183 final _usernameController = TextEditingController();
184 final _passwordController = TextEditingController();
185 Future<List<PveAccessDomainModel?>?>? _accessDomains;
186 PveAccessDomainModel? _selectedDomain;
187 final _formKey = GlobalKey<FormState>();
188 ProxmoxProgressModel _progressModel = ProxmoxProgressModel();
189 bool _submittButtonEnabled = true;
190
191 @override
192 void initState() {
193 super.initState();
194 final userModel = widget.userModel;
195 _progressModel = ProxmoxProgressModel();
196 if (!widget.isCreate! && userModel != null) {
197 _progressModel
198 ..inProgress = true
199 ..message = 'Connection test...';
200 _originController.text = userModel.origin?.toString() ?? '';
201 _accessDomains = _getAccessDomains();
202 _usernameController.text = userModel.username!;
203 if (widget.ticket!.isNotEmpty && userModel.activeSession) {
204 _onLoginButtonPressed(ticket: widget.ticket!, mRealm: userModel.realm);
205 }
206 }
207 }
208
209 @override
210 Widget build(BuildContext context) {
211 return Theme(
212 //data: ThemeData.dark().copyWith(accentColor: Color(0xFFE47225)),
213 data: ThemeData.dark().copyWith(
214 textButtonTheme: TextButtonThemeData(
215 style: TextButton.styleFrom(
216 foregroundColor: Colors.white,
217 backgroundColor: ProxmoxColors.orange,
218 disabledBackgroundColor: Colors.grey,
219 ),
220 ),
221 colorScheme: ColorScheme.dark().copyWith(
222 primary: ProxmoxColors.orange,
223 secondary: ProxmoxColors.orange,
224 onSecondary: ProxmoxColors.supportGrey
225 ),
226 ),
227 child: Scaffold(
228 backgroundColor: ProxmoxColors.supportBlue,
229 extendBodyBehindAppBar: true,
230 appBar: AppBar(
231 elevation: 0.0,
232 backgroundColor: Colors.transparent,
233 leading: IconButton(
234 icon: Icon(Icons.close),
235 onPressed: () => Navigator.of(context).pop(),
236 ),
237 ),
238 body: Stack(
239 children: [
240 SingleChildScrollView(
241 child: ConstrainedBox(
242 constraints: BoxConstraints.tightFor(
243 height: MediaQuery.of(context).size.height),
244 child: SafeArea(
245 child: Padding(
246 padding: const EdgeInsets.all(8.0),
247 child: FutureBuilder<List<PveAccessDomainModel?>?>(
248 future: _accessDomains,
249 builder: (context, snapshot) {
250 return Form(
251 key: _formKey,
252 onChanged: () {
253 setState(() {
254 _submittButtonEnabled =
255 _formKey.currentState!.validate();
256 });
257 },
258 child: Column(
259 mainAxisAlignment: MainAxisAlignment.center,
260 children: [
261 Expanded(
262 child: Container(
263 child: Column(
264 mainAxisAlignment:
265 MainAxisAlignment.center,
266 children: [
267 Image.asset(
268 'assets/images/proxmox_logo_symbol_wordmark.png',
269 package: 'proxmox_login_manager',
270 ),
271 ],
272 ),
273 ),
274 ),
275 ProxmoxLoginForm(
276 originController: _originController,
277 originValidator: (value) {
278 if (value == null || value.isEmpty) {
279 return 'Please enter origin';
280 }
281 try {
282 normalizeUrl(value);
283 return null;
284 } on FormatException catch (_) {
285 return 'Invalid URI';
286 } on Exception catch (e) {
287 return 'Invalid URI: $e';
288 }
289 },
290 usernameController: _usernameController,
291 passwordController: _passwordController,
292 accessDomains: snapshot.data,
293 selectedDomain: _selectedDomain,
294 onDomainChanged: (value) {
295 setState(() {
296 _selectedDomain = value;
297 });
298 },
299 onOriginSubmitted: _submittButtonEnabled
300 ? () {
301 final isValid =
302 _formKey.currentState!.validate();
303 setState(() {
304 _submittButtonEnabled = isValid;
305 });
306 if (isValid) {
307 setState(() {
308 _accessDomains =
309 _getAccessDomains();
310 });
311 }
312 }
313 : null,
314 onPasswordSubmitted: _submittButtonEnabled
315 ? () {
316 final isValid =
317 _formKey.currentState!.validate();
318 setState(() {
319 _submittButtonEnabled = isValid;
320 });
321 if (isValid) {
322 _onLoginButtonPressed();
323 }
324 }
325 : null,
326 ),
327 if (snapshot.hasData)
328 Expanded(
329 child: Align(
330 alignment: Alignment.bottomCenter,
331 child: Container(
332 width:
333 MediaQuery.of(context).size.width,
334 child: TextButton(
335 onPressed: _submittButtonEnabled
336 ? () {
337 final isValid = _formKey
338 .currentState!
339 .validate();
340 setState(() {
341 _submittButtonEnabled =
342 isValid;
343 });
344 if (isValid) {
345 _onLoginButtonPressed();
346 }
347 }
348 : null,
349 child: Text('Continue'),
350 ),
351 ),
352 ),
353 ),
354 if (!snapshot.hasData)
355 Expanded(
356 child: Align(
357 alignment: Alignment.bottomCenter,
358 child: Container(
359 width:
360 MediaQuery.of(context).size.width,
361 child: TextButton(
362 onPressed: _submittButtonEnabled
363 ? () {
364 final isValid = _formKey
365 .currentState!
366 .validate();
367 setState(() {
368 _submittButtonEnabled =
369 isValid;
370 });
371 if (isValid) {
372 setState(() {
373 _accessDomains =
374 _getAccessDomains();
375 });
376 }
377 }
378 : null,
379 child: Text('Continue'),
380 ),
381 ),
382 ),
383 ),
384 ],
385 ),
386 );
387 }),
388 ),
389 ),
390 ),
391 ),
392 if (_progressModel.inProgress)
393 ProxmoxProgressOverlay(message: _progressModel.message),
394 ],
395 ),
396 ),
397 );
398 }
399
400 Future<void> _onLoginButtonPressed(
401 {String ticket = '', String? mRealm}) async {
402 setState(() {
403 _progressModel
404 ..inProgress = true
405 ..message = 'Authenticating...';
406 });
407
408 try {
409 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
410
411 //cleaned form fields
412 final origin = normalizeUrl(_originController.text.trim());
413 final username = _usernameController.text.trim();
414 final password =
415 ticket.isNotEmpty ? ticket : _passwordController.text.trim();
416 final realm = _selectedDomain?.realm ?? mRealm;
417
418 var client = await proxclient.authenticate(
419 '$username@$realm', password, origin, settings.sslValidation!);
420
421 if (client.credentials.tfa) {
422 client = await Navigator.of(context).push(MaterialPageRoute(
423 builder: (context) => ProxmoxTfaForm(
424 apiClient: client,
425 ),
426 ));
427 }
428
429 final status = await client.getClusterStatus();
430 final hostname =
431 status.singleWhereOrNull((element) => element.local ?? false)?.name;
432 var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
433
434 if (widget.isCreate!) {
435 final newLogin = ProxmoxLoginModel((b) => b
436 ..origin = origin
437 ..username = username
438 ..realm = realm
439 ..productType = ProxmoxProductType.pve
440 ..ticket = client.credentials.ticket
441 ..hostname = hostname);
442
443 loginStorage = loginStorage!.rebuild((b) => b..logins.add(newLogin));
444 } else {
445 loginStorage = loginStorage!.rebuild((b) => b
446 ..logins.rebuildWhere(
447 (m) => m == widget.userModel,
448 (b) => b
449 ..ticket = client.credentials.ticket
450 ..hostname = hostname));
451 }
452 await loginStorage.saveToDisk();
453
454 Navigator.of(context).pop(client);
455 } on proxclient.ProxmoxApiException catch (e) {
456 print(e);
457 if (e.message.contains('No ticket')) {
458 showDialog(
459 context: context,
460 builder: (context) => AlertDialog(
461 title: Text('Version Error'),
462 content: Text(
463 'Proxmox VE version not supported, please update your instance to use this app.'),
464 actions: [
465 TextButton(
466 onPressed: () => Navigator.of(context).pop(),
467 child: Text('Close'),
468 ),
469 ],
470 ),
471 );
472 } else {
473 showDialog(
474 context: context,
475 builder: (context) => ProxmoxApiErrorDialog(
476 exception: e,
477 ),
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 }
489 }
490 setState(() {
491 _progressModel.inProgress = false;
492 });
493 }
494
495 Future<List<PveAccessDomainModel?>?> _getAccessDomains() async {
496 setState(() {
497 _progressModel
498 ..inProgress = true
499 ..message = 'Connection test...';
500 });
501 var host = _originController.text.trim();
502 var apiBaseUrl = normalizeUrl(host);
503
504 RegExp portRE = new RegExp(r":\d{1,5}$");
505
506 if (!portRE.hasMatch(host)) {
507 _originController.text += ':8006';
508 apiBaseUrl = apiBaseUrl.replace(port: 8006);
509 }
510
511 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
512 List<PveAccessDomainModel?>? response;
513 try {
514 response =
515 await proxclient.accessDomains(apiBaseUrl, settings.sslValidation!);
516 } on proxclient.ProxmoxApiException catch (e) {
517 showDialog(
518 context: context,
519 builder: (context) => ProxmoxApiErrorDialog(
520 exception: e,
521 ),
522 );
523 } catch (e, trace) {
524 print(e);
525 print(trace);
526 if (e.runtimeType == HandshakeException) {
527 showDialog(
528 context: context,
529 builder: (context) => ProxmoxCertificateErrorDialog(),
530 );
531 } else {
532 showDialog(
533 context: context,
534 builder: (context) => AlertDialog(
535 title: Text('Connection error'),
536 content: Text('Could not establish connection.'),
537 actions: [
538 TextButton(
539 onPressed: () => Navigator.of(context).pop(),
540 child: Text('Close'),
541 ),
542 ],
543 ),
544 );
545 }
546 }
547
548 response?.sort((a, b) => a!.realm.compareTo(b!.realm));
549
550 final selection = response?.singleWhere(
551 (e) => e!.realm == widget.userModel?.realm,
552 orElse: () => response?.first,
553 );
554
555 setState(() {
556 _progressModel.inProgress = false;
557 _selectedDomain = selection;
558 });
559
560 return response;
561 }
562
563 @override
564 void dispose() {
565 _originController.dispose();
566 _usernameController.dispose();
567 _passwordController.dispose();
568 super.dispose();
569 }
570 }
571
572 class ProxmoxProgressOverlay extends StatelessWidget {
573 const ProxmoxProgressOverlay({
574 Key? key,
575 required this.message,
576 }) : super(key: key);
577
578 final String message;
579
580 @override
581 Widget build(BuildContext context) {
582 return Container(
583 decoration: new BoxDecoration(color: Colors.black.withOpacity(0.5)),
584 child: Center(
585 child: Column(
586 mainAxisAlignment: MainAxisAlignment.center,
587 children: [
588 Text(
589 message,
590 style: TextStyle(
591 fontSize: 20,
592 ),
593 ),
594 Padding(
595 padding: const EdgeInsets.only(top: 20.0),
596 child: CircularProgressIndicator(),
597 )
598 ],
599 ),
600 ),
601 );
602 }
603 }
604
605 class ProxmoxApiErrorDialog extends StatelessWidget {
606 final proxclient.ProxmoxApiException exception;
607
608 const ProxmoxApiErrorDialog({
609 Key? key,
610 required this.exception,
611 }) : super(key: key);
612
613 @override
614 Widget build(BuildContext context) {
615 return AlertDialog(
616 title: Text('API Error'),
617 content: SingleChildScrollView(
618 child: Text(exception.message),
619 ),
620 actions: [
621 TextButton(
622 onPressed: () => Navigator.of(context).pop(),
623 child: Text('Close'),
624 ),
625 ],
626 );
627 }
628 }
629
630 class ProxmoxCertificateErrorDialog extends StatelessWidget {
631 const ProxmoxCertificateErrorDialog({
632 Key? key,
633 }) : super(key: key);
634
635 @override
636 Widget build(BuildContext context) {
637 return AlertDialog(
638 title: Text('Certificate error'),
639 content: SingleChildScrollView(
640 child: Column(
641 crossAxisAlignment: CrossAxisAlignment.start,
642 children: [
643 Text('Your connection is not private.'),
644 Text(
645 'Note: Consider to disable SSL validation,'
646 ' if you use a self signed, not commonly trusted, certificate.',
647 style: Theme.of(context).textTheme.caption,
648 ),
649 ],
650 ),
651 ),
652 actions: [
653 TextButton(
654 onPressed: () => Navigator.of(context).pop(),
655 child: Text('Close'),
656 ),
657 TextButton(
658 onPressed: () => Navigator.of(context).pushReplacement(
659 MaterialPageRoute(
660 builder: (context) => ProxmoxGeneralSettingsForm())),
661 child: Text('Settings'),
662 )
663 ],
664 );
665 }
666 }
667
668 Uri normalizeUrl(String urlText) {
669 if (urlText.startsWith('https://')) {
670 urlText = urlText.substring('https://'.length);
671 }
672 if (urlText.startsWith('http://')) {
673 throw new Exception("HTTP without TLS is not supported");
674 }
675
676 return Uri.https(urlText, '');
677 }