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