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';
15 class ProxmoxProgressModel {
16 bool inProgress = false;
17 String message = 'Loading...';
18 ProxmoxProgressModel({
19 this.inProgress = false,
20 this.message = 'Loading...',
24 // FIXME: copied from pve_flutter_frontend, re-use common set
26 static final Color orange = Color(0xFFE57000);
27 static final Color supportGrey = Color(0xFFABBABA);
28 static final Color supportBlue = Color(0xFF00617F);
31 class ProxmoxLoginForm extends StatefulWidget {
32 final TextEditingController originController;
33 final FormFieldValidator<String> originValidator;
34 final TextEditingController usernameController;
35 final TextEditingController passwordController;
36 final List<PveAccessDomainModel?>? accessDomains;
37 final PveAccessDomainModel? selectedDomain;
38 final ValueChanged<PveAccessDomainModel?> onDomainChanged;
39 final Function? onPasswordSubmitted;
40 final Function? onOriginSubmitted;
42 const ProxmoxLoginForm({
44 required this.originController,
45 required this.usernameController,
46 required this.passwordController,
47 required this.accessDomains,
48 required this.originValidator,
50 required this.onDomainChanged,
51 this.onPasswordSubmitted,
52 this.onOriginSubmitted,
56 _ProxmoxLoginFormState createState() => _ProxmoxLoginFormState();
59 class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
61 FocusNode? passwordFocusNode;
64 Widget build(BuildContext context) {
65 if (widget.accessDomains == null) {
67 decoration: InputDecoration(
68 icon: Icon(Icons.vpn_lock),
70 hintText: 'e.g. 192.168.1.2',
71 helperText: 'Protocol (https) and default port (8006) implied'),
72 controller: widget.originController,
73 validator: widget.originValidator,
74 onFieldSubmitted: (value) => widget.onOriginSubmitted!(),
80 mainAxisAlignment: MainAxisAlignment.center,
83 decoration: InputDecoration(
84 icon: Icon(Icons.vpn_lock),
87 controller: widget.originController,
91 decoration: InputDecoration(
92 icon: Icon(Icons.person),
93 labelText: 'Username',
95 controller: widget.usernameController,
98 return 'Please enter username';
102 autofillHints: [AutofillHints.username],
104 DropdownButtonFormField(
105 decoration: InputDecoration(icon: Icon(Icons.domain)),
106 items: widget.accessDomains!
107 .map((e) => DropdownMenuItem(
109 title: Text(e!.realm),
110 subtitle: Text(e.comment ?? ''),
115 onChanged: widget.onDomainChanged,
116 selectedItemBuilder: (context) =>
117 widget.accessDomains!.map((e) => Text(e!.realm)).toList(),
118 value: widget.selectedDomain,
123 decoration: InputDecoration(
124 icon: Icon(Icons.lock),
125 labelText: 'Password',
127 controller: widget.passwordController,
128 obscureText: _obscure,
130 focusNode: passwordFocusNode,
132 if (value!.isEmpty) {
133 return 'Please enter password';
137 onFieldSubmitted: (value) => widget.onPasswordSubmitted!(),
138 autofillHints: [AutofillHints.password],
141 alignment: Alignment.bottomRight,
143 constraints: BoxConstraints.tight(Size(58, 58)),
146 Icon(_obscure ? Icons.visibility : Icons.visibility_off),
147 onPressed: () => setState(() {
148 _obscure = !_obscure;
161 passwordFocusNode?.dispose();
166 class ProxmoxLoginPage extends StatefulWidget {
167 final ProxmoxLoginModel? userModel;
168 final bool? isCreate;
169 final String? ticket;
171 const ProxmoxLoginPage({
176 }) : super(key: key);
178 _ProxmoxLoginPageState createState() => _ProxmoxLoginPageState();
181 class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
182 final _originController = TextEditingController();
183 final _usernameController = TextEditingController();
184 final _passwordController = TextEditingController();
185 Future<List<PveAccessDomainModel?>?>? _accessDomains;
186 PveAccessDomainModel? _selectedDomain;
187 final _formKey = GlobalKey<FormState>();
188 ProxmoxProgressModel _progressModel = ProxmoxProgressModel();
189 bool _submittButtonEnabled = true;
194 final userModel = widget.userModel;
195 _progressModel = ProxmoxProgressModel();
196 if (!widget.isCreate! && userModel != null) {
199 ..message = 'Connection test...';
200 _originController.text = userModel.origin?.toString() ?? '';
201 _accessDomains = _getAccessDomains();
202 _usernameController.text = userModel.username!;
203 if (widget.ticket!.isNotEmpty && userModel.activeSession) {
204 _onLoginButtonPressed(ticket: widget.ticket!, mRealm: userModel.realm);
210 Widget build(BuildContext context) {
212 //data: ThemeData.dark().copyWith(accentColor: Color(0xFFE47225)),
213 data: ThemeData.dark().copyWith(
214 textButtonTheme: TextButtonThemeData(
215 style: TextButton.styleFrom(
216 foregroundColor: Colors.white,
217 backgroundColor: ProxmoxColors.orange,
218 disabledBackgroundColor: Colors.grey,
221 colorScheme: ColorScheme.dark().copyWith(
222 primary: ProxmoxColors.orange,
223 secondary: ProxmoxColors.orange,
224 onSecondary: ProxmoxColors.supportGrey
228 backgroundColor: ProxmoxColors.supportBlue,
229 extendBodyBehindAppBar: true,
232 backgroundColor: Colors.transparent,
234 icon: Icon(Icons.close),
235 onPressed: () => Navigator.of(context).pop(),
240 SingleChildScrollView(
241 child: ConstrainedBox(
242 constraints: BoxConstraints.tightFor(
243 height: MediaQuery.of(context).size.height),
246 padding: const EdgeInsets.all(8.0),
247 child: FutureBuilder<List<PveAccessDomainModel?>?>(
248 future: _accessDomains,
249 builder: (context, snapshot) {
254 _submittButtonEnabled =
255 _formKey.currentState!.validate();
259 mainAxisAlignment: MainAxisAlignment.center,
265 MainAxisAlignment.center,
268 'assets/images/proxmox_logo_symbol_wordmark.png',
269 package: 'proxmox_login_manager',
276 originController: _originController,
277 originValidator: (value) {
278 if (value == null || value.isEmpty) {
279 return 'Please enter origin';
284 } on FormatException catch (_) {
285 return 'Invalid URI';
286 } on Exception catch (e) {
287 return 'Invalid URI: $e';
290 usernameController: _usernameController,
291 passwordController: _passwordController,
292 accessDomains: snapshot.data,
293 selectedDomain: _selectedDomain,
294 onDomainChanged: (value) {
296 _selectedDomain = value;
299 onOriginSubmitted: _submittButtonEnabled
302 _formKey.currentState!.validate();
304 _submittButtonEnabled = isValid;
314 onPasswordSubmitted: _submittButtonEnabled
317 _formKey.currentState!.validate();
319 _submittButtonEnabled = isValid;
322 _onLoginButtonPressed();
327 if (snapshot.hasData)
330 alignment: Alignment.bottomCenter,
333 MediaQuery.of(context).size.width,
335 onPressed: _submittButtonEnabled
337 final isValid = _formKey
341 _submittButtonEnabled =
345 _onLoginButtonPressed();
349 child: Text('Continue'),
354 if (!snapshot.hasData)
357 alignment: Alignment.bottomCenter,
360 MediaQuery.of(context).size.width,
362 onPressed: _submittButtonEnabled
364 final isValid = _formKey
368 _submittButtonEnabled =
379 child: Text('Continue'),
392 if (_progressModel.inProgress)
393 ProxmoxProgressOverlay(message: _progressModel.message),
400 Future<void> _onLoginButtonPressed(
401 {String ticket = '', String? mRealm}) async {
405 ..message = 'Authenticating...';
409 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
411 //cleaned form fields
412 final origin = normalizeUrl(_originController.text.trim());
413 final username = _usernameController.text.trim();
415 ticket.isNotEmpty ? ticket : _passwordController.text.trim();
416 final realm = _selectedDomain?.realm ?? mRealm;
418 var client = await proxclient.authenticate(
419 '$username@$realm', password, origin, settings.sslValidation!);
421 if (client.credentials.tfa) {
422 client = await Navigator.of(context).push(MaterialPageRoute(
423 builder: (context) => ProxmoxTfaForm(
429 final status = await client.getClusterStatus();
431 status.singleWhereOrNull((element) => element.local ?? false)?.name;
432 var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
434 if (widget.isCreate!) {
435 final newLogin = ProxmoxLoginModel((b) => b
437 ..username = username
439 ..productType = ProxmoxProductType.pve
440 ..ticket = client.credentials.ticket
441 ..hostname = hostname);
443 loginStorage = loginStorage!.rebuild((b) => b..logins.add(newLogin));
445 loginStorage = loginStorage!.rebuild((b) => b
446 ..logins.rebuildWhere(
447 (m) => m == widget.userModel,
449 ..ticket = client.credentials.ticket
450 ..hostname = hostname));
452 await loginStorage.saveToDisk();
454 Navigator.of(context).pop(client);
455 } on proxclient.ProxmoxApiException catch (e) {
457 if (e.message.contains('No ticket')) {
460 builder: (context) => AlertDialog(
461 title: Text('Version Error'),
463 'Proxmox VE version not supported, please update your instance to use this app.'),
466 onPressed: () => Navigator.of(context).pop(),
467 child: Text('Close'),
475 builder: (context) => ProxmoxApiErrorDialog(
483 if (e.runtimeType == HandshakeException) {
486 builder: (context) => ProxmoxCertificateErrorDialog(),
491 _progressModel.inProgress = false;
495 Future<List<PveAccessDomainModel?>?> _getAccessDomains() async {
499 ..message = 'Connection test...';
501 var host = _originController.text.trim();
502 var apiBaseUrl = normalizeUrl(host);
504 RegExp portRE = new RegExp(r":\d{1,5}$");
506 if (!portRE.hasMatch(host)) {
507 _originController.text += ':8006';
508 apiBaseUrl = apiBaseUrl.replace(port: 8006);
511 final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
512 List<PveAccessDomainModel?>? response;
515 await proxclient.accessDomains(apiBaseUrl, settings.sslValidation!);
516 } on proxclient.ProxmoxApiException catch (e) {
519 builder: (context) => ProxmoxApiErrorDialog(
526 if (e.runtimeType == HandshakeException) {
529 builder: (context) => ProxmoxCertificateErrorDialog(),
534 builder: (context) => AlertDialog(
535 title: Text('Connection error'),
536 content: Text('Could not establish connection.'),
539 onPressed: () => Navigator.of(context).pop(),
540 child: Text('Close'),
548 response?.sort((a, b) => a!.realm.compareTo(b!.realm));
550 final selection = response?.singleWhere(
551 (e) => e!.realm == widget.userModel?.realm,
552 orElse: () => response?.first,
556 _progressModel.inProgress = false;
557 _selectedDomain = selection;
565 _originController.dispose();
566 _usernameController.dispose();
567 _passwordController.dispose();
572 class ProxmoxProgressOverlay extends StatelessWidget {
573 const ProxmoxProgressOverlay({
575 required this.message,
576 }) : super(key: key);
578 final String message;
581 Widget build(BuildContext context) {
583 decoration: new BoxDecoration(color: Colors.black.withOpacity(0.5)),
586 mainAxisAlignment: MainAxisAlignment.center,
595 padding: const EdgeInsets.only(top: 20.0),
596 child: CircularProgressIndicator(),
605 class ProxmoxApiErrorDialog extends StatelessWidget {
606 final proxclient.ProxmoxApiException exception;
608 const ProxmoxApiErrorDialog({
610 required this.exception,
611 }) : super(key: key);
614 Widget build(BuildContext context) {
616 title: Text('API Error'),
617 content: SingleChildScrollView(
618 child: Text(exception.message),
622 onPressed: () => Navigator.of(context).pop(),
623 child: Text('Close'),
630 class ProxmoxCertificateErrorDialog extends StatelessWidget {
631 const ProxmoxCertificateErrorDialog({
633 }) : super(key: key);
636 Widget build(BuildContext context) {
638 title: Text('Certificate error'),
639 content: SingleChildScrollView(
641 crossAxisAlignment: CrossAxisAlignment.start,
643 Text('Your connection is not private.'),
645 'Note: Consider to disable SSL validation,'
646 ' if you use a self signed, not commonly trusted, certificate.',
647 style: Theme.of(context).textTheme.caption,
654 onPressed: () => Navigator.of(context).pop(),
655 child: Text('Close'),
658 onPressed: () => Navigator.of(context).pushReplacement(
660 builder: (context) => ProxmoxGeneralSettingsForm())),
661 child: Text('Settings'),
668 Uri normalizeUrl(String urlText) {
669 if (urlText.startsWith('https://')) {
670 urlText = urlText.substring('https://'.length);
672 if (urlText.startsWith('http://')) {
673 throw new Exception("HTTP without TLS is not supported");
676 return Uri.https(urlText, '');