4 import 'package:biometric_storage/biometric_storage.dart';
5 import 'package:flutter/material.dart';
6 import 'package:collection/src/iterable_extensions.dart';
7 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
9 import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart';
10 import 'package:proxmox_login_manager/proxmox_general_settings_form.dart';
11 import 'package:proxmox_login_manager/proxmox_general_settings_model.dart';
12 import 'package:proxmox_login_manager/proxmox_login_model.dart';
13 import 'package:proxmox_login_manager/proxmox_tfa_form.dart';
14 import 'package:proxmox_login_manager/extension.dart';
15 import 'package:proxmox_login_manager/proxmox_password_store.dart';
17 class ProxmoxProgressModel {
19 String message = 'Loading...';
20 ProxmoxProgressModel({
22 this.message = 'Loading...',
26 // FIXME: copied from pve_flutter_frontend, re-use common set
28 static final Color orange = Color(0xFFE57000);
29 static final Color supportGrey = Color(0xFFABBABA);
30 static final Color supportBlue = Color(0xFF00617F);
33 class ProxmoxLoginForm extends StatefulWidget {
34 final TextEditingController originController;
35 final FormFieldValidator<String> originValidator;
36 final TextEditingController usernameController;
37 final TextEditingController passwordController;
38 final List<PveAccessDomainModel?>? accessDomains;
39 final PveAccessDomainModel? selectedDomain;
40 final ValueChanged<PveAccessDomainModel?> onDomainChanged;
41 final Function? onPasswordSubmitted;
42 final Function? onOriginSubmitted;
43 final Function? onSavePasswordChanged;
44 final bool? canSavePassword;
45 final bool? passwordSaved;
47 const ProxmoxLoginForm({
49 required this.originController,
50 required this.usernameController,
51 required this.passwordController,
52 required this.accessDomains,
53 required this.originValidator,
55 required this.onDomainChanged,
56 this.onPasswordSubmitted,
57 this.onOriginSubmitted,
58 this.onSavePasswordChanged,
64 _ProxmoxLoginFormState createState() => _ProxmoxLoginFormState();
67 class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
69 bool? _savePwCheckbox;
70 FocusNode? passwordFocusNode;
73 Widget build(BuildContext context) {
74 if (widget.accessDomains == null) {
76 decoration: InputDecoration(
77 icon: Icon(Icons.vpn_lock),
79 hintText: 'e.g. 192.168.1.2',
80 helperText: 'Protocol (https) and default port (8006) implied'),
81 controller: widget.originController,
82 validator: widget.originValidator,
83 onFieldSubmitted: (value) => widget.onOriginSubmitted!(),
89 mainAxisAlignment: MainAxisAlignment.center,
92 decoration: InputDecoration(
93 icon: Icon(Icons.vpn_lock),
96 controller: widget.originController,
100 decoration: InputDecoration(
101 icon: Icon(Icons.person),
102 labelText: 'Username',
104 controller: widget.usernameController,
106 if (value!.isEmpty) {
107 return 'Please enter username';
111 autofillHints: [AutofillHints.username],
113 DropdownButtonFormField(
114 decoration: InputDecoration(icon: Icon(Icons.domain)),
115 items: widget.accessDomains!
116 .map((e) => DropdownMenuItem(
118 title: Text(e!.realm),
119 subtitle: Text(e.comment ?? ''),
124 onChanged: widget.onDomainChanged,
125 selectedItemBuilder: (context) =>
126 widget.accessDomains!.map((e) => Text(e!.realm)).toList(),
127 value: widget.selectedDomain,
132 decoration: InputDecoration(
133 icon: Icon(Icons.lock),
134 labelText: 'Password',
136 controller: widget.passwordController,
137 obscureText: _obscure,
139 focusNode: passwordFocusNode,
141 if (value!.isEmpty) {
142 return 'Please enter password';
146 onFieldSubmitted: (value) => widget.onPasswordSubmitted!(),
147 autofillHints: [AutofillHints.password],
150 alignment: Alignment.bottomRight,
152 constraints: BoxConstraints.tight(Size(58, 58)),
155 Icon(_obscure ? Icons.visibility : Icons.visibility_off),
156 onPressed: () => setState(() {
157 _obscure = !_obscure;
163 if (widget.canSavePassword ?? false)
165 title: const Text('Save password'),
166 value: _savePwCheckbox ?? widget.passwordSaved ?? false,
168 if (widget.onSavePasswordChanged != null) {
169 widget.onSavePasswordChanged!(value!);
172 _savePwCheckbox = value!;
183 passwordFocusNode?.dispose();
188 class ProxmoxLoginPage extends StatefulWidget {
189 final ProxmoxLoginModel? userModel;
190 final bool? isCreate;
191 final String? ticket;
192 final String? password;
194 const ProxmoxLoginPage({
200 }) : super(key: key);
202 _ProxmoxLoginPageState createState() => _ProxmoxLoginPageState();
205 class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
206 final _originController = TextEditingController();
207 final _usernameController = TextEditingController();
208 final _passwordController = TextEditingController();
209 Future<List<PveAccessDomainModel?>?>? _accessDomains;
210 PveAccessDomainModel? _selectedDomain;
211 final _formKey = GlobalKey<FormState>();
212 ProxmoxProgressModel _progressModel = ProxmoxProgressModel();
213 bool _submittButtonEnabled = true;
214 bool _canSavePassword = false;
215 bool _savePasswordCB = false;
220 final userModel = widget.userModel;
221 _progressModel = ProxmoxProgressModel();
222 if (!widget.isCreate! && userModel != null) {
223 _originController.text = userModel.origin?.toString() ?? '';
224 _passwordController.text = widget.password ?? '';
225 _accessDomains = _getAccessDomains();
226 _usernameController.text = userModel.username!;
227 _savePasswordCB = widget.password != null;
228 if ((widget.ticket!.isNotEmpty && userModel.activeSession) ||
229 widget.password != null) {
230 _onLoginButtonPressed(ticket: widget.ticket!, mRealm: userModel.realm);
236 Widget build(BuildContext context) {
238 //data: ThemeData.dark().copyWith(accentColor: Color(0xFFE47225)),
239 data: ThemeData.dark().copyWith(
240 textButtonTheme: TextButtonThemeData(
241 style: TextButton.styleFrom(
242 foregroundColor: Colors.white,
243 backgroundColor: ProxmoxColors.orange,
244 disabledBackgroundColor: Colors.grey,
247 toggleableActiveColor: ProxmoxColors.orange,
248 colorScheme: ColorScheme.dark().copyWith(
249 primary: ProxmoxColors.orange,
250 secondary: ProxmoxColors.orange,
251 onSecondary: ProxmoxColors.supportGrey),
254 backgroundColor: ProxmoxColors.supportBlue,
255 extendBodyBehindAppBar: true,
258 backgroundColor: Colors.transparent,
260 icon: Icon(Icons.close),
261 onPressed: () => Navigator.of(context).pop(),
266 SingleChildScrollView(
267 child: ConstrainedBox(
268 constraints: BoxConstraints.tightFor(
269 height: MediaQuery.of(context).size.height),
272 padding: const EdgeInsets.all(8.0),
273 child: FutureBuilder<List<PveAccessDomainModel?>?>(
274 future: _accessDomains,
275 builder: (context, snapshot) {
280 _submittButtonEnabled =
281 _formKey.currentState!.validate();
285 mainAxisAlignment: MainAxisAlignment.center,
291 MainAxisAlignment.center,
294 'assets/images/proxmox_logo_symbol_wordmark.png',
295 package: 'proxmox_login_manager',
302 originController: _originController,
303 originValidator: (value) {
304 if (value == null || value.isEmpty) {
305 return 'Please enter origin';
310 } on FormatException catch (_) {
311 return 'Invalid URI';
312 } on Exception catch (e) {
313 return 'Invalid URI: $e';
316 usernameController: _usernameController,
317 passwordController: _passwordController,
318 accessDomains: snapshot.data,
319 selectedDomain: _selectedDomain,
320 onSavePasswordChanged: (value) {
321 _savePasswordCB = value;
323 canSavePassword: _canSavePassword,
324 passwordSaved: widget.password != null,
325 onDomainChanged: (value) {
327 _selectedDomain = value;
330 onOriginSubmitted: _submittButtonEnabled
333 _formKey.currentState!.validate();
335 _submittButtonEnabled = isValid;
345 onPasswordSubmitted: _submittButtonEnabled
348 _formKey.currentState!.validate();
350 _submittButtonEnabled = isValid;
353 _onLoginButtonPressed();
360 alignment: Alignment.bottomCenter,
362 width: MediaQuery.of(context).size.width,
364 onPressed: _submittButtonEnabled
366 final isValid = _formKey
370 _submittButtonEnabled =
374 if (snapshot.hasData) {
375 _onLoginButtonPressed();
385 child: Text('Continue'),
398 if (_progressModel.inProgress > 0)
399 ProxmoxProgressOverlay(message: _progressModel.message),
406 Future<void> _onLoginButtonPressed(
407 {String ticket = '', String? mRealm}) async {
411 ..message = 'Authenticating...';
415 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
416 //cleaned form fields
417 final origin = normalizeUrl(_originController.text.trim());
418 final username = _usernameController.text.trim();
419 final String enteredPassword = _passwordController.text.trim();
420 final String? savedPassword = widget.password;
422 final password = ticket.isNotEmpty ? ticket : enteredPassword;
423 final realm = _selectedDomain?.realm ?? mRealm;
425 var client = await proxclient.authenticate(
426 '$username@$realm', password, origin, settings.sslValidation!);
428 if (client.credentials.tfa) {
429 client = await Navigator.of(context).push(MaterialPageRoute(
430 builder: (context) => ProxmoxTfaForm(
436 final status = await client.getClusterStatus();
438 status.singleWhereOrNull((element) => element.local ?? false)?.name;
439 var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
441 final savePW = enteredPassword != '' &&
443 enteredPassword != savedPassword;
444 final deletePW = enteredPassword != '' && !savePW && !_savePasswordCB;
447 if (widget.isCreate!) {
448 final newLogin = ProxmoxLoginModel((b) => b
450 ..username = username
452 ..productType = ProxmoxProductType.pve
453 ..ticket = client.credentials.ticket
454 ..passwordSaved = savePW
455 ..hostname = hostname);
457 loginStorage = loginStorage!.rebuild((b) => b..logins.add(newLogin));
458 id = newLogin.identifier;
460 loginStorage = loginStorage!.rebuild((b) => b
461 ..logins.rebuildWhere(
462 (m) => m == widget.userModel,
464 ..ticket = client.credentials.ticket
466 savePW || (deletePW ? false : b.passwordSaved ?? false)
467 ..hostname = hostname));
468 id = widget.userModel!.identifier;
473 await savePassword(id, enteredPassword);
474 } else if (deletePW) {
475 await deletePassword(id);
478 await loginStorage.saveToDisk();
480 Navigator.of(context).pop(client);
481 } on proxclient.ProxmoxApiException catch (e) {
483 if (e.message.contains('No ticket')) {
486 builder: (context) => AlertDialog(
487 title: Text('Version Error'),
489 'Proxmox VE version not supported, please update your instance to use this app.'),
492 onPressed: () => Navigator.of(context).pop(),
493 child: Text('Close'),
501 builder: (context) => ProxmoxApiErrorDialog(
509 if (e.runtimeType == HandshakeException) {
512 builder: (context) => ProxmoxCertificateErrorDialog(),
517 _progressModel.inProgress -= 1;
521 Future<List<PveAccessDomainModel?>?> _getAccessDomains() async {
525 ..message = 'Connection test...';
528 final canSavePW = await BiometricStorage().canAuthenticate();
530 _canSavePassword = canSavePW == CanAuthenticateResponse.success;
533 var host = _originController.text.trim();
534 var apiBaseUrl = normalizeUrl(host);
536 RegExp portRE = new RegExp(r":\d{1,5}$");
538 if (!portRE.hasMatch(host)) {
539 _originController.text += ':8006';
540 apiBaseUrl = apiBaseUrl.replace(port: 8006);
543 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
544 List<PveAccessDomainModel?>? response;
547 await proxclient.accessDomains(apiBaseUrl, settings.sslValidation!);
548 } on proxclient.ProxmoxApiException catch (e) {
551 builder: (context) => ProxmoxApiErrorDialog(
558 if (e.runtimeType == HandshakeException) {
561 builder: (context) => ProxmoxCertificateErrorDialog(),
566 builder: (context) => AlertDialog(
567 title: Text('Connection error'),
568 content: Text('Could not establish connection.'),
571 onPressed: () => Navigator.of(context).pop(),
572 child: Text('Close'),
580 response?.sort((a, b) => a!.realm.compareTo(b!.realm));
582 final selection = response?.singleWhere(
583 (e) => e!.realm == widget.userModel?.realm,
584 orElse: () => response?.first,
588 _progressModel.inProgress -= 1;
589 _selectedDomain = selection;
597 _originController.dispose();
598 _usernameController.dispose();
599 _passwordController.dispose();
604 class ProxmoxProgressOverlay extends StatelessWidget {
605 const ProxmoxProgressOverlay({
607 required this.message,
608 }) : super(key: key);
610 final String message;
613 Widget build(BuildContext context) {
615 decoration: new BoxDecoration(color: Colors.black.withOpacity(0.5)),
618 mainAxisAlignment: MainAxisAlignment.center,
627 padding: const EdgeInsets.only(top: 20.0),
628 child: CircularProgressIndicator(),
637 class ProxmoxApiErrorDialog extends StatelessWidget {
638 final proxclient.ProxmoxApiException exception;
640 const ProxmoxApiErrorDialog({
642 required this.exception,
643 }) : super(key: key);
646 Widget build(BuildContext context) {
648 title: Text('API Error'),
649 content: SingleChildScrollView(
650 child: Text(exception.message),
654 onPressed: () => Navigator.of(context).pop(),
655 child: Text('Close'),
662 class ProxmoxCertificateErrorDialog extends StatelessWidget {
663 const ProxmoxCertificateErrorDialog({
665 }) : super(key: key);
668 Widget build(BuildContext context) {
670 title: Text('Certificate error'),
671 content: SingleChildScrollView(
673 crossAxisAlignment: CrossAxisAlignment.start,
675 Text('Your connection is not private.'),
677 'Note: Consider to disable SSL validation,'
678 ' if you use a self signed, not commonly trusted, certificate.',
679 style: Theme.of(context).textTheme.caption,
686 onPressed: () => Navigator.of(context).pop(),
687 child: Text('Close'),
690 onPressed: () => Navigator.of(context).pushReplacement(
692 builder: (context) => ProxmoxGeneralSettingsForm())),
693 child: Text('Settings'),
700 Uri normalizeUrl(String urlText) {
701 if (urlText.startsWith('https://')) {
702 urlText = urlText.substring('https://'.length);
704 if (urlText.startsWith('http://')) {
705 throw new Exception("HTTP without TLS is not supported");
708 return Uri.https(urlText, '');