]> git.proxmox.com Git - flutter/proxmox_login_manager.git/blame - lib/proxmox_login_form.dart
initial commit
[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';
11
12class ProxmoxProgressModel {
13 bool inProgress;
14 String message;
15 ProxmoxProgressModel({
16 this.inProgress = false,
17 this.message = 'Loading...',
18 });
19}
20
21class 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
45class _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
155class 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
168class _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
480class 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
514class 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
539class 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}