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,
343 MainAxisAlignment.center,
346 'assets/images/proxmox_logo_symbol_wordmark.png',
347 package: 'proxmox_login_manager',
354 originController: _originController,
355 originValidator: (value) {
356 if (value == null || value.isEmpty) {
357 return 'Please enter origin';
362 } on FormatException catch (_) {
363 return 'Invalid URI';
364 } on Exception catch (e) {
365 return 'Invalid URI: $e';
368 usernameController: _usernameController,
369 passwordController: _passwordController,
370 accessDomains: snapshot.data,
371 selectedDomain: _selectedDomain,
372 onSavePasswordChanged: (value) {
373 _savePasswordCB = value;
375 canSavePassword: _canSavePassword,
376 passwordSaved: widget.password != null,
377 onDomainChanged: (value) {
379 _selectedDomain = value;
382 onOriginSubmitted: () {
384 _formKey.currentState!.validate();
386 _submittButtonEnabled = isValid;
390 _accessDomains = _getAccessDomains();
394 onPasswordSubmitted: _submittButtonEnabled
397 _formKey.currentState!.validate();
399 _submittButtonEnabled = isValid;
402 _onLoginButtonPressed();
409 alignment: Alignment.bottomCenter,
411 width: MediaQuery.of(context).size.width,
413 onPressed: _submittButtonEnabled
415 final isValid = _formKey
419 _submittButtonEnabled =
423 if (snapshot.hasData) {
424 _onLoginButtonPressed();
434 child: const Text('Continue'),
447 if (_progressModel.inProgress > 0)
448 ProxmoxProgressOverlay(message: _progressModel.message),
455 Future<void> _onLoginButtonPressed(
456 {String ticket = '', String? mRealm}) async {
460 ..message = 'Authenticating...';
464 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
465 //cleaned form fields
466 final origin = normalizeUrl(_originController.text.trim());
467 final username = _usernameController.text.trim();
468 final String enteredPassword = _passwordController.text.trim();
469 final String? savedPassword = widget.password;
471 final password = ticket.isNotEmpty ? ticket : enteredPassword;
472 final realm = _selectedDomain?.realm ?? mRealm;
474 var client = await proxclient.authenticate(
475 '$username@$realm', password, origin, settings.sslValidation!);
477 if (client.credentials.tfa != null &&
478 client.credentials.tfa!.kinds().isNotEmpty) {
479 if (!mounted) return;
480 ProxmoxApiClient? tfaclient =
481 await Navigator.of(context).push(MaterialPageRoute(
482 builder: (context) => ProxmoxTfaForm(
487 if (tfaclient != null) {
491 _progressModel.inProgress -= 1;
497 final status = await client.getClusterStatus();
499 status.singleWhereOrNull((element) => element.local ?? false)?.name;
500 var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
502 final savePW = enteredPassword != '' &&
504 enteredPassword != savedPassword;
505 final deletePW = enteredPassword != '' && !savePW && !_savePasswordCB;
508 if (widget.isCreate!) {
509 final newLogin = ProxmoxLoginModel((b) => b
511 ..username = username
513 ..productType = ProxmoxProductType.pve
514 ..ticket = client.credentials.ticket
515 ..passwordSaved = savePW
516 ..hostname = hostname);
518 loginStorage = loginStorage!.rebuild((b) => b..logins.add(newLogin));
519 id = newLogin.identifier;
521 loginStorage = loginStorage!.rebuild((b) => b
522 ..logins.rebuildWhere(
523 (m) => m == widget.userModel,
525 ..ticket = client.credentials.ticket
527 savePW || (deletePW ? false : b.passwordSaved ?? false)
528 ..hostname = hostname));
529 id = widget.userModel!.identifier;
534 await savePassword(id, enteredPassword);
535 } else if (deletePW) {
536 await deletePassword(id);
539 await loginStorage.saveToDisk();
542 Navigator.of(context).pop(client);
544 } on proxclient.ProxmoxApiException catch (e) {
546 if (!mounted) return;
547 if (e.message.contains('No ticket')) {
550 builder: (context) => AlertDialog(
551 title: const Text('Version Error'),
553 'Proxmox VE version not supported, please update your instance to use this app.'),
556 onPressed: () => Navigator.of(context).pop(),
557 child: const Text('Close'),
565 builder: (context) => ProxmoxApiErrorDialog(
574 if (e.runtimeType == HandshakeException) {
577 builder: (context) => const ProxmoxCertificateErrorDialog(),
582 builder: (context) => ConnectionErrorDialog(exception: e),
588 _progressModel.inProgress -= 1;
592 Future<List<PveAccessDomainModel?>?> _loadAccessDomains(Uri uri) async {
593 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
594 List<PveAccessDomainModel?>? response;
596 response = await proxclient.accessDomains(uri, settings.sslValidation!);
597 } on proxclient.ProxmoxApiException catch (e) {
601 builder: (context) => ProxmoxApiErrorDialog(
606 } on HandshakeException {
610 builder: (context) => const ProxmoxCertificateErrorDialog(),
617 Future<List<PveAccessDomainModel?>?> _tryLoadAccessDomains(Uri uri) async {
618 List<PveAccessDomainModel?>? response;
620 response = await _loadAccessDomains(uri);
625 builder: (context) => ConnectionErrorDialog(
634 Future<List<PveAccessDomainModel?>?> _getAccessDomains() async {
638 ..message = 'Connecting...';
641 final canSavePW = await canSavePassword();
643 _canSavePassword = canSavePW;
646 var host = _originController.text.trim();
647 var apiBaseUrl = normalizeUrl(host);
649 RegExp portRE = RegExp(r":\d{1,5}$");
651 List<PveAccessDomainModel?>? response;
653 if (portRE.hasMatch(host)) {
654 response = await _tryLoadAccessDomains(apiBaseUrl);
656 // try to guess the port, 8006 first, and then 443
657 apiBaseUrl = apiBaseUrl.replace(port: 8006);
659 response = await _loadAccessDomains(apiBaseUrl);
660 if (response != null) {
661 _originController.text = '$host:8006';
664 // we were no port given, and we couldn't reach on port 8006, retry with 443
665 apiBaseUrl = apiBaseUrl.replace(port: 443);
666 response = await _tryLoadAccessDomains(apiBaseUrl);
667 if (response != null) {
668 _originController.text = '$host:443';
673 response?.sort((a, b) => a!.realm.compareTo(b!.realm));
675 final selection = response?.singleWhere(
676 (e) => e!.realm == widget.userModel?.realm,
677 orElse: () => response?.first,
681 _progressModel.inProgress -= 1;
682 _selectedDomain = selection;
690 _originController.dispose();
691 _usernameController.dispose();
692 _passwordController.dispose();
697 class ProxmoxProgressOverlay extends StatelessWidget {
698 const ProxmoxProgressOverlay({
700 required this.message,
701 }) : super(key: key);
703 final String message;
706 Widget build(BuildContext context) {
708 decoration: BoxDecoration(color: Colors.black.withOpacity(0.5)),
711 mainAxisAlignment: MainAxisAlignment.center,
715 style: const TextStyle(
720 padding: EdgeInsets.only(top: 20.0),
721 child: CircularProgressIndicator(),
730 class ConnectionErrorDialog extends StatelessWidget {
731 final Object exception;
733 const ConnectionErrorDialog({
735 required this.exception,
736 }) : super(key: key);
739 Widget build(BuildContext context) {
741 title: const Text('Connection error'),
742 content: Text('Could not establish connection: $exception'),
745 onPressed: () => Navigator.of(context).pop(),
746 child: const Text('Close'),
753 class ProxmoxApiErrorDialog extends StatelessWidget {
754 final proxclient.ProxmoxApiException exception;
756 const ProxmoxApiErrorDialog({
758 required this.exception,
759 }) : super(key: key);
762 Widget build(BuildContext context) {
764 title: const Text('API Error'),
765 content: SingleChildScrollView(
766 child: Text(exception.message),
770 onPressed: () => Navigator.of(context).pop(),
771 child: const Text('Close'),
778 class ProxmoxCertificateErrorDialog extends StatelessWidget {
779 const ProxmoxCertificateErrorDialog({
781 }) : super(key: key);
784 Widget build(BuildContext context) {
786 title: const Text('Certificate error'),
787 content: SingleChildScrollView(
789 crossAxisAlignment: CrossAxisAlignment.start,
791 const Text('Your connection is not private.'),
793 'Note: Consider to disable SSL validation,'
794 ' if you use a self signed, not commonly trusted, certificate.',
795 style: Theme.of(context).textTheme.bodySmall,
802 onPressed: () => Navigator.of(context).pop(),
803 child: const Text('Close'),
806 onPressed: () => Navigator.of(context).pushReplacement(
808 builder: (context) => const ProxmoxGeneralSettingsForm())),
809 child: const Text('Settings'),
816 Uri normalizeUrl(String urlText) {
817 if (urlText.startsWith('https://')) {
818 urlText = urlText.substring('https://'.length);
820 if (urlText.startsWith('http://')) {
821 throw Exception("HTTP without TLS is not supported");
824 return Uri.https(urlText, '');