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'
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 import 'package:proxmox_login_manager/proxmox_password_store.dart';
16 class ProxmoxProgressModel {
18 String message = 'Loading...';
19 ProxmoxProgressModel({
21 this.message = 'Loading...',
25 // FIXME: copied from pve_flutter_frontend, re-use common set
27 static const Color orange = Color(0xFFE57000);
28 static const Color supportGrey = Color(0xFFABBABA);
29 static const Color supportBlue = Color(0xFF00617F);
32 class ProxmoxLoginForm extends StatefulWidget {
33 final TextEditingController originController;
34 final FormFieldValidator<String> originValidator;
35 final TextEditingController usernameController;
36 final TextEditingController passwordController;
37 final List<PveAccessDomainModel?>? accessDomains;
38 final PveAccessDomainModel? selectedDomain;
39 final ValueChanged<PveAccessDomainModel?> onDomainChanged;
40 final Function? onPasswordSubmitted;
41 final Function onOriginSubmitted;
42 final Function? onSavePasswordChanged;
43 final bool? canSavePassword;
44 final bool? passwordSaved;
46 const ProxmoxLoginForm({
48 required this.originController,
49 required this.usernameController,
50 required this.passwordController,
51 required this.accessDomains,
52 required this.originValidator,
54 required this.onDomainChanged,
55 this.onPasswordSubmitted,
56 required this.onOriginSubmitted,
57 this.onSavePasswordChanged,
63 State<ProxmoxLoginForm> createState() => _ProxmoxLoginFormState();
66 class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
68 bool? _savePwCheckbox;
69 FocusNode? passwordFocusNode;
72 Widget build(BuildContext context) {
73 if (widget.accessDomains == null) {
75 decoration: const InputDecoration(
76 icon: Icon(Icons.vpn_lock),
78 hintText: 'e.g. 192.168.1.2',
80 'Protocol (https) and default port (8006 or 443) implied'),
81 textInputAction: TextInputAction.next,
82 controller: widget.originController,
83 validator: widget.originValidator,
84 onFieldSubmitted: (value) => widget.onOriginSubmitted(),
90 mainAxisAlignment: MainAxisAlignment.center,
93 decoration: const InputDecoration(
94 icon: Icon(Icons.vpn_lock),
97 controller: widget.originController,
101 decoration: const InputDecoration(
102 icon: Icon(Icons.person),
103 labelText: 'Username',
105 controller: widget.usernameController,
107 if (value!.isEmpty) {
108 return 'Please enter username';
112 autofillHints: const [AutofillHints.username],
114 DropdownButtonFormField(
115 decoration: const InputDecoration(icon: Icon(Icons.domain)),
116 items: widget.accessDomains!
117 .map((e) => DropdownMenuItem(
120 title: Text(e!.realm),
121 subtitle: Text(e.comment ?? ''),
125 onChanged: widget.onDomainChanged,
126 selectedItemBuilder: (context) =>
127 widget.accessDomains!.map((e) => Text(e!.realm)).toList(),
128 value: widget.selectedDomain,
133 decoration: const InputDecoration(
134 icon: Icon(Icons.lock),
135 labelText: 'Password',
137 controller: widget.passwordController,
138 obscureText: _obscure,
140 focusNode: passwordFocusNode,
142 if (value!.isEmpty) {
143 return 'Please enter password';
147 onFieldSubmitted: (value) => widget.onPasswordSubmitted!(),
148 autofillHints: const [AutofillHints.password],
151 alignment: Alignment.bottomRight,
153 constraints: BoxConstraints.tight(const Size(58, 58)),
156 Icon(_obscure ? Icons.visibility : Icons.visibility_off),
157 onPressed: () => setState(() {
158 _obscure = !_obscure;
164 if (widget.canSavePassword ?? false)
166 title: const Text('Save password'),
167 value: _savePwCheckbox ?? widget.passwordSaved ?? false,
169 if (widget.onSavePasswordChanged != null) {
170 widget.onSavePasswordChanged!(value!);
173 _savePwCheckbox = value!;
184 passwordFocusNode?.dispose();
189 class ProxmoxLoginPage extends StatefulWidget {
190 final ProxmoxLoginModel? userModel;
191 final bool? isCreate;
192 final String? ticket;
193 final String? password;
195 const ProxmoxLoginPage({
201 }) : super(key: key);
203 State<ProxmoxLoginPage> createState() => _ProxmoxLoginPageState();
206 class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
207 final _originController = TextEditingController();
208 final _usernameController = TextEditingController();
209 final _passwordController = TextEditingController();
210 Future<List<PveAccessDomainModel?>?>? _accessDomains;
211 PveAccessDomainModel? _selectedDomain;
212 final _formKey = GlobalKey<FormState>();
213 ProxmoxProgressModel _progressModel = ProxmoxProgressModel();
214 bool _submittButtonEnabled = true;
215 bool _canSavePassword = false;
216 bool _savePasswordCB = false;
221 final userModel = widget.userModel;
222 _progressModel = ProxmoxProgressModel();
223 if (!widget.isCreate! && userModel != null) {
224 _originController.text = userModel.origin?.toString() ?? '';
225 // Uri does not append 443 for https, so we do it manually
226 if (userModel.origin != null &&
227 userModel.origin!.scheme == "https" &&
228 userModel.origin!.port == 443) {
229 _originController.text += ":443";
231 _passwordController.text = widget.password ?? '';
232 _accessDomains = _getAccessDomains();
233 _usernameController.text = userModel.username!;
234 _savePasswordCB = widget.password != null;
235 if ((widget.ticket!.isNotEmpty && userModel.activeSession) ||
236 widget.password != null) {
237 _onLoginButtonPressed(ticket: widget.ticket!, mRealm: userModel.realm);
243 Widget build(BuildContext context) {
245 //data: ThemeData.dark().copyWith(accentColor: Color(0xFFE47225)),
246 data: ThemeData.dark().copyWith(
247 textButtonTheme: TextButtonThemeData(
248 style: TextButton.styleFrom(
249 foregroundColor: Colors.white,
250 backgroundColor: ProxmoxColors.orange,
251 disabledBackgroundColor: Colors.grey,
254 colorScheme: const ColorScheme.dark().copyWith(
255 primary: ProxmoxColors.orange,
256 secondary: ProxmoxColors.orange,
257 onSecondary: ProxmoxColors.supportGrey),
258 checkboxTheme: CheckboxThemeData(
259 fillColor: MaterialStateProperty.resolveWith<Color?>(
260 (Set<MaterialState> states) {
261 if (states.contains(MaterialState.disabled)) {
264 if (states.contains(MaterialState.selected)) {
265 return ProxmoxColors.orange;
270 radioTheme: RadioThemeData(
271 fillColor: MaterialStateProperty.resolveWith<Color?>(
272 (Set<MaterialState> states) {
273 if (states.contains(MaterialState.disabled)) {
276 if (states.contains(MaterialState.selected)) {
277 return ProxmoxColors.orange;
282 switchTheme: SwitchThemeData(
283 thumbColor: MaterialStateProperty.resolveWith<Color?>(
284 (Set<MaterialState> states) {
285 if (states.contains(MaterialState.disabled)) {
288 if (states.contains(MaterialState.selected)) {
289 return ProxmoxColors.orange;
293 trackColor: MaterialStateProperty.resolveWith<Color?>(
294 (Set<MaterialState> states) {
295 if (states.contains(MaterialState.disabled)) {
298 if (states.contains(MaterialState.selected)) {
299 return ProxmoxColors.orange;
306 backgroundColor: ProxmoxColors.supportBlue,
307 extendBodyBehindAppBar: true,
310 backgroundColor: Colors.transparent,
312 icon: const Icon(Icons.close),
313 onPressed: () => Navigator.of(context).pop(),
318 SingleChildScrollView(
319 child: ConstrainedBox(
320 constraints: BoxConstraints.tightFor(
321 height: MediaQuery.of(context).size.height),
324 padding: const EdgeInsets.all(8.0),
325 child: FutureBuilder<List<PveAccessDomainModel?>?>(
326 future: _accessDomains,
327 builder: (context, snapshot) {
332 _submittButtonEnabled =
333 _formKey.currentState!.validate();
337 mainAxisAlignment: MainAxisAlignment.center,
341 mainAxisAlignment: MainAxisAlignment.center,
344 'assets/images/proxmox_logo_symbol_wordmark.png',
345 package: 'proxmox_login_manager',
351 originController: _originController,
352 originValidator: (value) {
353 if (value == null || value.isEmpty) {
354 return 'Please enter origin';
359 } on FormatException catch (_) {
360 return 'Invalid URI';
361 } on Exception catch (e) {
362 return 'Invalid URI: $e';
365 usernameController: _usernameController,
366 passwordController: _passwordController,
367 accessDomains: snapshot.data,
368 selectedDomain: _selectedDomain,
369 onSavePasswordChanged: (value) {
370 _savePasswordCB = value;
372 canSavePassword: _canSavePassword,
373 passwordSaved: widget.password != null,
374 onDomainChanged: (value) {
376 _selectedDomain = value;
379 onOriginSubmitted: () {
381 _formKey.currentState!.validate();
383 _submittButtonEnabled = isValid;
387 _accessDomains = _getAccessDomains();
391 onPasswordSubmitted: _submittButtonEnabled
394 _formKey.currentState!.validate();
396 _submittButtonEnabled = isValid;
399 _onLoginButtonPressed();
406 alignment: Alignment.bottomCenter,
408 width: MediaQuery.of(context).size.width,
410 onPressed: _submittButtonEnabled
412 final isValid = _formKey
416 _submittButtonEnabled =
420 if (snapshot.hasData) {
421 _onLoginButtonPressed();
431 child: const Text('Continue'),
444 if (_progressModel.inProgress > 0)
445 ProxmoxProgressOverlay(message: _progressModel.message),
452 Future<void> _onLoginButtonPressed(
453 {String ticket = '', String? mRealm}) async {
457 ..message = 'Authenticating...';
461 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
462 //cleaned form fields
463 final origin = normalizeUrl(_originController.text.trim());
464 final username = _usernameController.text.trim();
465 final String enteredPassword = _passwordController.text.trim();
466 final String? savedPassword = widget.password;
468 final password = ticket.isNotEmpty ? ticket : enteredPassword;
469 final realm = _selectedDomain?.realm ?? mRealm;
471 var client = await proxclient.authenticate(
472 '$username@$realm', password, origin, settings.sslValidation!);
474 if (client.credentials.tfa != null &&
475 client.credentials.tfa!.kinds().isNotEmpty) {
476 if (!mounted) return;
477 ProxmoxApiClient? tfaclient =
478 await Navigator.of(context).push(MaterialPageRoute(
479 builder: (context) => ProxmoxTfaForm(
484 if (tfaclient != null) {
488 _progressModel.inProgress -= 1;
494 final status = await client.getClusterStatus();
496 status.singleWhereOrNull((element) => element.local ?? false)?.name;
497 var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
499 final savePW = enteredPassword != '' &&
501 enteredPassword != savedPassword;
502 final deletePW = enteredPassword != '' && !savePW && !_savePasswordCB;
505 if (widget.isCreate!) {
506 final newLogin = ProxmoxLoginModel((b) => b
508 ..username = username
510 ..productType = ProxmoxProductType.pve
511 ..ticket = client.credentials.ticket
512 ..passwordSaved = savePW
513 ..hostname = hostname);
515 loginStorage = loginStorage!.rebuild((b) => b..logins.add(newLogin));
516 id = newLogin.identifier;
518 loginStorage = loginStorage!.rebuild((b) => b
519 ..logins.rebuildWhere(
520 (m) => m == widget.userModel,
522 ..ticket = client.credentials.ticket
524 savePW || (deletePW ? false : b.passwordSaved ?? false)
525 ..hostname = hostname));
526 id = widget.userModel!.identifier;
531 await savePassword(id, enteredPassword);
532 } else if (deletePW) {
533 await deletePassword(id);
536 await loginStorage.saveToDisk();
539 Navigator.of(context).pop(client);
541 } on proxclient.ProxmoxApiException catch (e) {
543 if (!mounted) return;
544 if (e.message.contains('No ticket')) {
547 builder: (context) => AlertDialog(
548 title: const Text('Version Error'),
550 'Proxmox VE version not supported, please update your instance to use this app.'),
553 onPressed: () => Navigator.of(context).pop(),
554 child: const Text('Close'),
562 builder: (context) => ProxmoxApiErrorDialog(
571 if (e.runtimeType == HandshakeException) {
574 builder: (context) => const ProxmoxCertificateErrorDialog(),
579 builder: (context) => ConnectionErrorDialog(exception: e),
585 _progressModel.inProgress -= 1;
589 Future<List<PveAccessDomainModel?>?> _loadAccessDomains(Uri uri) async {
590 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
591 List<PveAccessDomainModel?>? response;
593 response = await proxclient.accessDomains(uri, settings.sslValidation!);
594 } on proxclient.ProxmoxApiException catch (e) {
598 builder: (context) => ProxmoxApiErrorDialog(
603 } on HandshakeException {
607 builder: (context) => const ProxmoxCertificateErrorDialog(),
614 Future<List<PveAccessDomainModel?>?> _tryLoadAccessDomains(Uri uri) async {
615 List<PveAccessDomainModel?>? response;
617 response = await _loadAccessDomains(uri);
622 builder: (context) => ConnectionErrorDialog(
631 Future<List<PveAccessDomainModel?>?> _getAccessDomains() async {
635 ..message = 'Connecting...';
638 final canSavePW = await canSavePassword();
640 _canSavePassword = canSavePW;
643 var host = _originController.text.trim();
644 var apiBaseUrl = normalizeUrl(host);
646 RegExp portRE = RegExp(r":\d{1,5}$");
648 List<PveAccessDomainModel?>? response;
650 if (portRE.hasMatch(host)) {
651 response = await _tryLoadAccessDomains(apiBaseUrl);
653 // try to guess the port, 8006 first, and then 443
654 apiBaseUrl = apiBaseUrl.replace(port: 8006);
656 response = await _loadAccessDomains(apiBaseUrl);
657 if (response != null) {
658 _originController.text = '$host:8006';
661 // we were no port given, and we couldn't reach on port 8006, retry with 443
662 apiBaseUrl = apiBaseUrl.replace(port: 443);
663 response = await _tryLoadAccessDomains(apiBaseUrl);
664 if (response != null) {
665 _originController.text = '$host:443';
670 response?.sort((a, b) => a!.realm.compareTo(b!.realm));
672 final selection = response?.singleWhere(
673 (e) => e!.realm == widget.userModel?.realm,
674 orElse: () => response?.first,
678 _progressModel.inProgress -= 1;
679 _selectedDomain = selection;
687 _originController.dispose();
688 _usernameController.dispose();
689 _passwordController.dispose();
694 class ProxmoxProgressOverlay extends StatelessWidget {
695 const ProxmoxProgressOverlay({
697 required this.message,
698 }) : super(key: key);
700 final String message;
703 Widget build(BuildContext context) {
705 decoration: BoxDecoration(color: Colors.black.withOpacity(0.5)),
708 mainAxisAlignment: MainAxisAlignment.center,
712 style: const TextStyle(
717 padding: EdgeInsets.only(top: 20.0),
718 child: CircularProgressIndicator(),
727 class ConnectionErrorDialog extends StatelessWidget {
728 final Object exception;
730 const ConnectionErrorDialog({
732 required this.exception,
733 }) : super(key: key);
736 Widget build(BuildContext context) {
738 title: const Text('Connection error'),
739 content: Text('Could not establish connection: $exception'),
742 onPressed: () => Navigator.of(context).pop(),
743 child: const Text('Close'),
750 class ProxmoxApiErrorDialog extends StatelessWidget {
751 final proxclient.ProxmoxApiException exception;
753 const ProxmoxApiErrorDialog({
755 required this.exception,
756 }) : super(key: key);
759 Widget build(BuildContext context) {
761 title: const Text('API Error'),
762 content: SingleChildScrollView(
763 child: Text(exception.message),
767 onPressed: () => Navigator.of(context).pop(),
768 child: const Text('Close'),
775 class ProxmoxCertificateErrorDialog extends StatelessWidget {
776 const ProxmoxCertificateErrorDialog({
778 }) : super(key: key);
781 Widget build(BuildContext context) {
783 title: const Text('Certificate error'),
784 content: SingleChildScrollView(
786 crossAxisAlignment: CrossAxisAlignment.start,
788 const Text('Your connection is not private.'),
790 'Note: Consider to disable SSL validation,'
791 ' if you use a self signed, not commonly trusted, certificate.',
792 style: Theme.of(context).textTheme.bodySmall,
799 onPressed: () => Navigator.of(context).pop(),
800 child: const Text('Close'),
803 onPressed: () => Navigator.of(context).pushReplacement(
805 builder: (context) => const ProxmoxGeneralSettingsForm())),
806 child: const Text('Settings'),
813 Uri normalizeUrl(String urlText) {
814 if (urlText.startsWith('https://')) {
815 urlText = urlText.substring('https://'.length);
817 if (urlText.startsWith('http://')) {
818 throw Exception("HTTP without TLS is not supported");
821 return Uri.https(urlText, '');