4 import 'package:flutter/material.dart';
5 import 'package:collection/collection.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({
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;
532 await savePassword(id, enteredPassword);
533 } else if (deletePW) {
534 await deletePassword(id);
539 builder: (context) => AlertDialog(
540 title: const Text('Password saving error'),
543 mainAxisSize: MainAxisSize.min,
545 const Text('Could not save or delete password.'),
547 title: const Text('Details'),
548 children: [Text(e.toString())],
554 onPressed: () => Navigator.of(context).pop(),
555 child: const Text('Continue')),
560 await loginStorage.saveToDisk();
563 Navigator.of(context).pop(client);
565 } on proxclient.ProxmoxApiException catch (e) {
567 if (!mounted) return;
568 if (e.message.contains('No ticket')) {
571 builder: (context) => AlertDialog(
572 title: const Text('Version Error'),
574 'Proxmox VE version not supported, please update your instance to use this app.'),
577 onPressed: () => Navigator.of(context).pop(),
578 child: const Text('Close'),
586 builder: (context) => ProxmoxApiErrorDialog(
595 if (e.runtimeType == HandshakeException) {
598 builder: (context) => const ProxmoxCertificateErrorDialog(),
603 builder: (context) => ConnectionErrorDialog(exception: e),
609 _progressModel.inProgress -= 1;
613 Future<List<PveAccessDomainModel?>?> _loadAccessDomains(Uri uri) async {
614 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
615 List<PveAccessDomainModel?>? response;
617 response = await proxclient.accessDomains(uri, settings.sslValidation!);
618 } on proxclient.ProxmoxApiException catch (e) {
622 builder: (context) => ProxmoxApiErrorDialog(
627 } on HandshakeException {
631 builder: (context) => const ProxmoxCertificateErrorDialog(),
638 Future<List<PveAccessDomainModel?>?> _tryLoadAccessDomains(Uri uri) async {
639 List<PveAccessDomainModel?>? response;
641 response = await _loadAccessDomains(uri);
646 builder: (context) => ConnectionErrorDialog(
655 Future<List<PveAccessDomainModel?>?> _getAccessDomains() async {
659 ..message = 'Connecting...';
662 final canSavePW = await canSavePassword();
664 _canSavePassword = canSavePW;
667 var host = _originController.text.trim();
668 var apiBaseUrl = normalizeUrl(host);
670 RegExp portRE = RegExp(r":\d{1,5}$");
672 List<PveAccessDomainModel?>? response;
674 if (portRE.hasMatch(host)) {
675 response = await _tryLoadAccessDomains(apiBaseUrl);
677 // try to guess the port, 8006 first, and then 443
678 apiBaseUrl = apiBaseUrl.replace(port: 8006);
680 response = await _loadAccessDomains(apiBaseUrl);
681 if (response != null) {
682 _originController.text = '$host:8006';
685 // we were no port given, and we couldn't reach on port 8006, retry with 443
686 apiBaseUrl = apiBaseUrl.replace(port: 443);
687 response = await _tryLoadAccessDomains(apiBaseUrl);
688 if (response != null) {
689 _originController.text = '$host:443';
694 response?.sort((a, b) => a!.realm.compareTo(b!.realm));
696 final selection = response?.singleWhere(
697 (e) => e!.realm == widget.userModel?.realm,
698 orElse: () => response?.first,
702 _progressModel.inProgress -= 1;
703 _selectedDomain = selection;
711 _originController.dispose();
712 _usernameController.dispose();
713 _passwordController.dispose();
718 class ProxmoxProgressOverlay extends StatelessWidget {
719 const ProxmoxProgressOverlay({
721 required this.message,
724 final String message;
727 Widget build(BuildContext context) {
729 decoration: BoxDecoration(color: Colors.black.withOpacity(0.5)),
732 mainAxisAlignment: MainAxisAlignment.center,
736 style: const TextStyle(
741 padding: EdgeInsets.only(top: 20.0),
742 child: CircularProgressIndicator(),
751 class ConnectionErrorDialog extends StatelessWidget {
752 final Object exception;
754 const ConnectionErrorDialog({
756 required this.exception,
760 Widget build(BuildContext context) {
762 title: const Text('Connection error'),
763 content: Text('Could not establish connection: $exception'),
766 onPressed: () => Navigator.of(context).pop(),
767 child: const Text('Close'),
774 class ProxmoxApiErrorDialog extends StatelessWidget {
775 final proxclient.ProxmoxApiException exception;
777 const ProxmoxApiErrorDialog({
779 required this.exception,
783 Widget build(BuildContext context) {
785 title: const Text('API Error'),
786 content: SingleChildScrollView(
787 child: Text(exception.message),
791 onPressed: () => Navigator.of(context).pop(),
792 child: const Text('Close'),
799 class ProxmoxCertificateErrorDialog extends StatelessWidget {
800 const ProxmoxCertificateErrorDialog({
805 Widget build(BuildContext context) {
807 title: const Text('Certificate error'),
808 content: SingleChildScrollView(
810 crossAxisAlignment: CrossAxisAlignment.start,
812 const Text('Your connection is not private.'),
814 'Note: Consider to disable SSL validation,'
815 ' if you use a self signed, not commonly trusted, certificate.',
816 style: Theme.of(context).textTheme.bodySmall,
823 onPressed: () => Navigator.of(context).pop(),
824 child: const Text('Close'),
827 onPressed: () => Navigator.of(context).pushReplacement(
829 builder: (context) => const ProxmoxGeneralSettingsForm())),
830 child: const Text('Settings'),
837 Uri normalizeUrl(String urlText) {
838 if (urlText.startsWith('https://')) {
839 urlText = urlText.substring('https://'.length);
841 if (urlText.startsWith('http://')) {
842 throw Exception("HTTP without TLS is not supported");
845 return Uri.https(urlText, '');