]> git.proxmox.com Git - flutter/proxmox_login_manager.git/commitdiff
initial commit
authorTim Marx <t.marx@proxmox.com>
Thu, 23 Jul 2020 12:43:51 +0000 (14:43 +0200)
committerTim Marx <t.marx@proxmox.com>
Thu, 23 Jul 2020 12:43:51 +0000 (14:43 +0200)
Signed-off-by: Tim Marx <t.marx@proxmox.com>
16 files changed:
.gitignore [new file with mode: 0644]
.metadata [new file with mode: 0644]
LICENSE [new file with mode: 0644]
README.md [new file with mode: 0644]
assets/fonts/Proxmox.ttf [new file with mode: 0644]
lib/proxmox_general_settings_form.dart [new file with mode: 0644]
lib/proxmox_general_settings_model.dart [new file with mode: 0644]
lib/proxmox_login_form.dart [new file with mode: 0644]
lib/proxmox_login_manager.dart [new file with mode: 0644]
lib/proxmox_login_model.dart [new file with mode: 0644]
lib/proxmox_login_selector.dart [new file with mode: 0644]
lib/proxmox_tfa_form.dart [new file with mode: 0644]
lib/serializers.dart [new file with mode: 0644]
pubspec.lock [new file with mode: 0644]
pubspec.yaml [new file with mode: 0644]
test/proxmox_login_manager_test.dart [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..f7a928b
--- /dev/null
@@ -0,0 +1,78 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.buildlog/
+.history
+.svn/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.packages
+.pub-cache/
+.pub/
+build/
+
+# avoid commiting generated files
+*.g.dart
+
+# Android related
+**/android/**/gradle-wrapper.jar
+**/android/.gradle
+**/android/captures/
+**/android/gradlew
+**/android/gradlew.bat
+**/android/local.properties
+**/android/**/GeneratedPluginRegistrant.java
+
+# iOS/XCode related
+**/ios/**/*.mode1v3
+**/ios/**/*.mode2v3
+**/ios/**/*.moved-aside
+**/ios/**/*.pbxuser
+**/ios/**/*.perspectivev3
+**/ios/**/*sync/
+**/ios/**/.sconsign.dblite
+**/ios/**/.tags*
+**/ios/**/.vagrant/
+**/ios/**/DerivedData/
+**/ios/**/Icon?
+**/ios/**/Pods/
+**/ios/**/.symlinks/
+**/ios/**/profile
+**/ios/**/xcuserdata
+**/ios/.generated/
+**/ios/Flutter/App.framework
+**/ios/Flutter/Flutter.framework
+**/ios/Flutter/Flutter.podspec
+**/ios/Flutter/Generated.xcconfig
+**/ios/Flutter/app.flx
+**/ios/Flutter/app.zip
+**/ios/Flutter/flutter_assets/
+**/ios/Flutter/flutter_export_environment.sh
+**/ios/ServiceDefinitions.json
+**/ios/Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!**/ios/**/default.mode1v3
+!**/ios/**/default.mode2v3
+!**/ios/**/default.pbxuser
+!**/ios/**/default.perspectivev3
+!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
diff --git a/.metadata b/.metadata
new file mode 100644 (file)
index 0000000..4e650c6
--- /dev/null
+++ b/.metadata
@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+  revision: 9c3f0faa6da061f4f69ee1988a1416be985360f0
+  channel: master
+
+project_type: package
diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
index 0000000..ba75c69
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1 @@
+TODO: Add your license here.
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..748a836
--- /dev/null
+++ b/README.md
@@ -0,0 +1,30 @@
+# proxmox_login_manager
+
+A basic login management library which handles authentication in connection
+with the proxmox_dart_api_client library.
+
+You need to build the model classes before using it, to do this use:
+```Bash
+flutter packages pub run build_runner build
+```
+Basic usage:
+```dart
+ProxmoxLoginSelector(
+    onLogin: (ProxmoxApiClient client) => whatever you want to do with the client,
+),
+```
+This will give you an authenticated ProxmoxApiClient for usage in your project.
+
+The latest session will be saved and can be recovered.
+```dart
+loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
+final apiClient = await loginStorage.recoverLatestSession();
+```
+This will either return an authenticated ProxmoxApiClient or will result in an
+Exception (ProxmoxApiException).
+
+To clear all session on logout you can use:
+```dart
+ProxmoxLoginStorage.fromLocalStorage()
+                .then((storage) => storage?.invalidateAllSessions());
+```
\ No newline at end of file
diff --git a/assets/fonts/Proxmox.ttf b/assets/fonts/Proxmox.ttf
new file mode 100644 (file)
index 0000000..27a2e31
Binary files /dev/null and b/assets/fonts/Proxmox.ttf differ
diff --git a/lib/proxmox_general_settings_form.dart b/lib/proxmox_general_settings_form.dart
new file mode 100644 (file)
index 0000000..f3fdd44
--- /dev/null
@@ -0,0 +1,58 @@
+import 'package:flutter/material.dart';
+import 'package:proxmox_login_manager/proxmox_general_settings_model.dart';
+
+class ProxmoxGeneralSettingsForm extends StatefulWidget {
+  @override
+  _ProxmoxGeneralSettingsFormState createState() =>
+      _ProxmoxGeneralSettingsFormState();
+}
+
+class _ProxmoxGeneralSettingsFormState
+    extends State<ProxmoxGeneralSettingsForm> {
+  Future<ProxmoxGeneralSettingsModel> _settings;
+  @override
+  void initState() {
+    super.initState();
+    _settings = ProxmoxGeneralSettingsModel.fromLocalStorage();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(
+        title: Text('Settings'),
+      ),
+      body: FutureBuilder<ProxmoxGeneralSettingsModel>(
+          future: _settings,
+          builder: (context, snaptshot) {
+            if (snaptshot.hasData) {
+              final settings = snaptshot.data;
+              return SingleChildScrollView(
+                child: Column(
+                  children: [
+                    SwitchListTile(
+                      title: Text('Validate SSL connections'),
+                      subtitle: Text('e.g. validates certificates'),
+                      value: settings.sslValidation,
+                      onChanged: (value) async {
+                        await settings
+                            .rebuild((b) => b.sslValidation = value)
+                            .toLocalStorage();
+                        setState(() {
+                          _settings =
+                              ProxmoxGeneralSettingsModel.fromLocalStorage();
+                        });
+                      },
+                    )
+                  ],
+                ),
+              );
+            }
+
+            return Center(
+              child: CircularProgressIndicator(),
+            );
+          }),
+    );
+  }
+}
diff --git a/lib/proxmox_general_settings_model.dart b/lib/proxmox_general_settings_model.dart
new file mode 100644 (file)
index 0000000..72fc0f7
--- /dev/null
@@ -0,0 +1,50 @@
+import 'dart:convert';
+
+import 'package:built_value/built_value.dart';
+import 'package:built_value/serializer.dart';
+import 'package:proxmox_login_manager/serializers.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+part 'proxmox_general_settings_model.g.dart';
+
+abstract class ProxmoxGeneralSettingsModel
+    implements
+        Built<ProxmoxGeneralSettingsModel, ProxmoxGeneralSettingsModelBuilder> {
+  bool get sslValidation;
+
+  ProxmoxGeneralSettingsModel._();
+  factory ProxmoxGeneralSettingsModel(
+          [void Function(ProxmoxGeneralSettingsModelBuilder) updates]) =
+      _$ProxmoxGeneralSettingsModel;
+
+  factory ProxmoxGeneralSettingsModel.defaultValues() =>
+      ProxmoxGeneralSettingsModel((b) => b..sslValidation = true);
+
+  Object toJson() {
+    return serializers.serializeWith(
+        ProxmoxGeneralSettingsModel.serializer, this);
+  }
+
+  static ProxmoxGeneralSettingsModel fromJson(Object json) {
+    return serializers.deserializeWith(
+        ProxmoxGeneralSettingsModel.serializer, json);
+  }
+
+  static Serializer<ProxmoxGeneralSettingsModel> get serializer =>
+      _$proxmoxGeneralSettingsModelSerializer;
+
+  static Future<ProxmoxGeneralSettingsModel> fromLocalStorage() async {
+    final SharedPreferences prefs = await SharedPreferences.getInstance();
+    if (prefs.containsKey('ProxmoxGeneralSettings')) {
+      final decodedJson =
+          json.decode(prefs.getString('ProxmoxGeneralSettings'));
+      return fromJson(decodedJson);
+    }
+    return ProxmoxGeneralSettingsModel.defaultValues();
+  }
+
+  Future<void> toLocalStorage() async {
+    final SharedPreferences prefs = await SharedPreferences.getInstance();
+    prefs.setString('ProxmoxGeneralSettings', json.encode(toJson()));
+  }
+}
diff --git a/lib/proxmox_login_form.dart b/lib/proxmox_login_form.dart
new file mode 100644 (file)
index 0000000..cffa7be
--- /dev/null
@@ -0,0 +1,575 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
+    as proxclient;
+import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart';
+import 'package:proxmox_login_manager/proxmox_general_settings_form.dart';
+import 'package:proxmox_login_manager/proxmox_general_settings_model.dart';
+import 'package:proxmox_login_manager/proxmox_login_model.dart';
+import 'package:proxmox_login_manager/proxmox_tfa_form.dart';
+
+class ProxmoxProgressModel {
+  bool inProgress;
+  String message;
+  ProxmoxProgressModel({
+    this.inProgress = false,
+    this.message = 'Loading...',
+  });
+}
+
+class ProxmoxLoginForm extends StatefulWidget {
+  final TextEditingController originController;
+  final FormFieldValidator<String> originValidator;
+  final TextEditingController usernameController;
+  final TextEditingController passwordController;
+  final List<PveAccessDomainModel> accessDomains;
+  final PveAccessDomainModel selectedDomain;
+  final ValueChanged<PveAccessDomainModel> onDomainChanged;
+
+  const ProxmoxLoginForm({
+    Key key,
+    @required this.originController,
+    @required this.usernameController,
+    @required this.passwordController,
+    @required this.accessDomains,
+    @required this.originValidator,
+    this.selectedDomain,
+    @required this.onDomainChanged,
+  }) : super(key: key);
+
+  @override
+  _ProxmoxLoginFormState createState() => _ProxmoxLoginFormState();
+}
+
+class _ProxmoxLoginFormState extends State<ProxmoxLoginForm> {
+  bool _obscure = true;
+  FocusNode passwordFocusNode;
+
+  @override
+  void initState() {
+    super.initState();
+
+    if (widget.usernameController.text.isNotEmpty) {
+      passwordFocusNode = FocusNode();
+      passwordFocusNode.requestFocus();
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (widget.accessDomains == null) {
+      return TextFormField(
+        decoration: InputDecoration(
+            icon: Icon(Icons.vpn_lock),
+            labelText: 'Origin',
+            hintText: 'e.g. 192.168.1.2',
+            helperText: 'Protocol (https) and default port (8006) implied'),
+        controller: widget.originController,
+        validator: widget.originValidator,
+      );
+    }
+
+    return Column(
+      mainAxisAlignment: MainAxisAlignment.center,
+      children: [
+        TextFormField(
+          decoration: InputDecoration(
+            icon: Icon(Icons.vpn_lock),
+            labelText: 'Origin',
+          ),
+          controller: widget.originController,
+          enabled: false,
+        ),
+        TextFormField(
+          decoration: InputDecoration(
+            icon: Icon(Icons.person),
+            labelText: 'Username',
+          ),
+          controller: widget.usernameController,
+          validator: (value) {
+            if (value.isEmpty) {
+              return 'Please enter username';
+            }
+            return null;
+          },
+        ),
+        DropdownButtonFormField(
+          decoration: InputDecoration(icon: Icon(Icons.domain)),
+          items: widget.accessDomains
+              .map((e) => DropdownMenuItem(
+                    child: ListTile(
+                      title: Text(e.realm),
+                      subtitle: Text(e.comment ?? ''),
+                    ),
+                    value: e,
+                  ))
+              .toList(),
+          onChanged: widget.onDomainChanged,
+          selectedItemBuilder: (context) =>
+              widget.accessDomains.map((e) => Text(e.realm)).toList(),
+          value: widget.selectedDomain,
+        ),
+        Stack(
+          children: [
+            TextFormField(
+              decoration: InputDecoration(
+                icon: Icon(Icons.lock),
+                labelText: 'Password',
+              ),
+              controller: widget.passwordController,
+              obscureText: _obscure,
+              autocorrect: false,
+              focusNode: passwordFocusNode,
+              validator: (value) {
+                if (value.isEmpty) {
+                  return 'Please enter password';
+                }
+                return null;
+              },
+            ),
+            Align(
+              alignment: Alignment.bottomRight,
+              child: IconButton(
+                constraints: BoxConstraints.tight(Size(58, 58)),
+                iconSize: 24,
+                icon: Icon(_obscure ? Icons.visibility : Icons.visibility_off),
+                onPressed: () => setState(() {
+                  _obscure = !_obscure;
+                }),
+              ),
+            )
+          ],
+        ),
+      ],
+    );
+  }
+
+  @override
+  void dispose() {
+    passwordFocusNode?.dispose();
+    super.dispose();
+  }
+}
+
+class ProxmoxLoginPage extends StatefulWidget {
+  final ProxmoxLoginModel userModel;
+  final bool isCreate;
+
+  const ProxmoxLoginPage({
+    Key key,
+    this.userModel,
+    this.isCreate,
+  }) : super(key: key);
+  @override
+  _ProxmoxLoginPageState createState() => _ProxmoxLoginPageState();
+}
+
+class _ProxmoxLoginPageState extends State<ProxmoxLoginPage> {
+  final _originController = TextEditingController();
+  final _usernameController = TextEditingController();
+  final _passwordController = TextEditingController();
+  Future<List<PveAccessDomainModel>> _accessDomains;
+  PveAccessDomainModel _selectedDomain;
+  final _formKey = GlobalKey<FormState>();
+  ProxmoxProgressModel _progressModel;
+  bool _submittButtonEnabled = true;
+  @override
+  void initState() {
+    super.initState();
+    final userModel = widget.userModel;
+    _progressModel = ProxmoxProgressModel();
+    if (!widget.isCreate && userModel != null) {
+      _progressModel
+        ..inProgress = true
+        ..message = 'Connection test...';
+      _originController.text =
+          '${userModel.origin?.host}:${userModel.origin?.port}';
+      _accessDomains = _getAccessDomains();
+      _usernameController.text = userModel.username;
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Theme(
+      data: ThemeData.dark().copyWith(accentColor: Color(0xFFE47225)),
+      child: Material(
+        color: Theme.of(context).primaryColor,
+        child: Stack(
+          children: [
+            SingleChildScrollView(
+              child: ConstrainedBox(
+                constraints: BoxConstraints.tightFor(
+                    height: MediaQuery.of(context).size.height),
+                child: Padding(
+                  padding: const EdgeInsets.all(8.0),
+                  child: FutureBuilder<List<PveAccessDomainModel>>(
+                      future: _accessDomains,
+                      builder: (context, snapshot) {
+                        return Form(
+                          key: _formKey,
+                          onChanged: () {
+                            setState(() {
+                              _submittButtonEnabled =
+                                  _formKey.currentState.validate();
+                            });
+                          },
+                          child: Column(
+                            mainAxisAlignment: MainAxisAlignment.center,
+                            children: [
+                              Expanded(
+                                child: Container(
+                                  child: Column(
+                                    mainAxisAlignment: MainAxisAlignment.center,
+                                    children: [
+                                      Text(
+                                        'PROXMOX',
+                                        style: TextStyle(
+                                          fontFamily: 'Proxmox',
+                                          fontSize: 36,
+                                        ),
+                                      ),
+                                      Text(
+                                        'Open Source',
+                                        style: TextStyle(
+                                          fontSize: 18,
+                                        ),
+                                      ),
+                                    ],
+                                  ),
+                                ),
+                              ),
+                              ProxmoxLoginForm(
+                                originController: _originController,
+                                originValidator: (value) {
+                                  if (value.isEmpty) {
+                                    return 'Please enter origin';
+                                  }
+                                  if (value.startsWith('https://') ||
+                                      value.startsWith('http://')) {
+                                    return 'Do not prefix with scheme';
+                                  }
+                                  try {
+                                    Uri.https(value, '');
+                                    return null;
+                                  } on FormatException catch (_) {
+                                    return 'Invalid URI';
+                                  }
+                                },
+                                usernameController: _usernameController,
+                                passwordController: _passwordController,
+                                accessDomains: snapshot.data,
+                                selectedDomain: _selectedDomain,
+                                onDomainChanged: (value) {
+                                  setState(() {
+                                    _selectedDomain = value;
+                                  });
+                                },
+                              ),
+                              if (snapshot.hasData)
+                                Expanded(
+                                  child: Align(
+                                    alignment: Alignment.bottomCenter,
+                                    child: Container(
+                                      width: MediaQuery.of(context).size.width,
+                                      child: FlatButton(
+                                        onPressed: _submittButtonEnabled
+                                            ? () {
+                                                final isValid = _formKey
+                                                    .currentState
+                                                    .validate();
+                                                setState(() {
+                                                  _submittButtonEnabled =
+                                                      isValid;
+                                                });
+                                                if (isValid) {
+                                                  _onLoginButtonPressed();
+                                                }
+                                              }
+                                            : null,
+                                        color: Color(0xFFE47225),
+                                        disabledColor: Colors.grey,
+                                        child: Text('Continue'),
+                                      ),
+                                    ),
+                                  ),
+                                ),
+                              if (!snapshot.hasData)
+                                Expanded(
+                                  child: Align(
+                                    alignment: Alignment.bottomCenter,
+                                    child: Container(
+                                      width: MediaQuery.of(context).size.width,
+                                      child: FlatButton(
+                                        onPressed: _submittButtonEnabled
+                                            ? () {
+                                                final isValid = _formKey
+                                                    .currentState
+                                                    .validate();
+                                                setState(() {
+                                                  _submittButtonEnabled =
+                                                      isValid;
+                                                });
+                                                if (isValid) {
+                                                  setState(() {
+                                                    _accessDomains =
+                                                        _getAccessDomains();
+                                                  });
+                                                }
+                                              }
+                                            : null,
+                                        color: Color(0xFFE47225),
+                                        child: Text('Continue'),
+                                        disabledColor: Colors.grey,
+                                      ),
+                                    ),
+                                  ),
+                                ),
+                            ],
+                          ),
+                        );
+                      }),
+                ),
+              ),
+            ),
+            if (_progressModel.inProgress)
+              ProxmoxProgressOverlay(message: _progressModel.message),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Future<void> _onLoginButtonPressed() async {
+    setState(() {
+      _progressModel
+        ..inProgress = true
+        ..message = 'Authenticating...';
+    });
+
+    try {
+      final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
+      final origin = Uri.https(_originController.text, '');
+      var client = await proxclient.authenticate(
+          '${_usernameController.text}@${_selectedDomain.realm}',
+          _passwordController.text,
+          origin,
+          settings.sslValidation);
+
+      if (client.credentials.tfa) {
+        client = await Navigator.of(context).push(MaterialPageRoute(
+          builder: (context) => ProxmoxTfaForm(
+            apiClient: client,
+          ),
+        ));
+      }
+
+      var loginStorage = await ProxmoxLoginStorage.fromLocalStorage();
+
+      if (widget.isCreate) {
+        final newLogin = ProxmoxLoginModel((b) => b
+          ..origin = origin
+          ..username = _usernameController.text
+          ..realm = _selectedDomain.realm
+          ..productType = ProxmoxProductType.pve
+          ..ticket = client.credentials.ticket);
+        loginStorage = loginStorage.rebuild((b) => b..logins.add(newLogin));
+      } else {
+        loginStorage = loginStorage.rebuild((b) => b
+          ..logins.remove(widget.userModel)
+          ..logins.add(widget.userModel
+              .rebuild((b) => b..ticket = client.credentials.ticket)));
+      }
+      await loginStorage.saveToDisk();
+
+      Navigator.of(context).pop(client);
+    } on proxclient.ProxmoxApiException catch (e) {
+      print(e);
+      showDialog(
+        context: context,
+        builder: (context) => ProxmoxApiErrorDialog(
+          exception: e,
+        ),
+      );
+    } catch (e, trace) {
+      print(e);
+      print(trace);
+      if (e.runtimeType == HandshakeException) {
+        showDialog(
+          context: context,
+          builder: (context) => ProxmoxCertificateErrorDialog(),
+        );
+      }
+    }
+    setState(() {
+      _progressModel.inProgress = false;
+    });
+  }
+
+  Future<List<PveAccessDomainModel>> _getAccessDomains() async {
+    setState(() {
+      _progressModel
+        ..inProgress = true
+        ..message = 'Connection test...';
+    });
+    var apiBaseUrl = Uri.https(_originController.text, '');
+
+    if (!apiBaseUrl.hasPort) {
+      _originController.text += ':8006';
+      apiBaseUrl = apiBaseUrl.replace(port: 8006);
+    }
+
+    final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
+    List<PveAccessDomainModel> response;
+    try {
+      response =
+          await proxclient.accessDomains(apiBaseUrl, settings.sslValidation);
+    } on proxclient.ProxmoxApiException catch (e) {
+      print(e);
+      showDialog(
+        context: context,
+        builder: (context) => ProxmoxApiErrorDialog(
+          exception: e,
+        ),
+      );
+    } catch (e, trace) {
+      print(e);
+      print(trace);
+      if (e.runtimeType == HandshakeException) {
+        showDialog(
+          context: context,
+          builder: (context) => ProxmoxCertificateErrorDialog(),
+        );
+      } else {
+        showDialog(
+          context: context,
+          builder: (context) => AlertDialog(
+            title: Text('Connection error'),
+            content: Text('Could not establish connection.'),
+            actions: [
+              FlatButton(
+                onPressed: () => Navigator.of(context).pop(),
+                child: Text('Close'),
+              ),
+            ],
+          ),
+        );
+      }
+    }
+    final selection = response?.singleWhere(
+      (e) => e.realm == widget.userModel?.realm,
+      orElse: () => response?.first,
+    );
+    setState(() {
+      _progressModel.inProgress = false;
+      _selectedDomain = selection;
+    });
+    return response;
+  }
+
+  @override
+  void dispose() {
+    _originController.dispose();
+    _usernameController.dispose();
+    _passwordController.dispose();
+    super.dispose();
+  }
+}
+
+class ProxmoxProgressOverlay extends StatelessWidget {
+  const ProxmoxProgressOverlay({
+    Key key,
+    @required this.message,
+  }) : super(key: key);
+
+  final String message;
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      decoration: new BoxDecoration(color: Colors.black.withOpacity(0.5)),
+      child: Center(
+        child: Column(
+          mainAxisAlignment: MainAxisAlignment.center,
+          children: [
+            Text(
+              message,
+              style: TextStyle(
+                color: Theme.of(context).accentColor,
+                fontSize: 20,
+              ),
+            ),
+            Padding(
+              padding: const EdgeInsets.only(top: 20.0),
+              child: CircularProgressIndicator(),
+            )
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+class ProxmoxApiErrorDialog extends StatelessWidget {
+  final proxclient.ProxmoxApiException exception;
+
+  const ProxmoxApiErrorDialog({
+    Key key,
+    @required this.exception,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return AlertDialog(
+      title: Text('API Error'),
+      content: SingleChildScrollView(
+        child: Text(exception.message),
+      ),
+      actions: [
+        FlatButton(
+          onPressed: () => Navigator.of(context).pop(),
+          child: Text('Close'),
+        ),
+      ],
+    );
+  }
+}
+
+class ProxmoxCertificateErrorDialog extends StatelessWidget {
+  const ProxmoxCertificateErrorDialog({
+    Key key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return AlertDialog(
+      title: Text('Certificate error'),
+      content: SingleChildScrollView(
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Text('Your connection is not private.'),
+            Text(
+              'Note: Consider to disable SSL validation,'
+              ' if you use a self signed, not commonly trusted, certificate.',
+              style: Theme.of(context).textTheme.caption,
+            ),
+          ],
+        ),
+      ),
+      actions: [
+        FlatButton(
+          onPressed: () => Navigator.of(context).pop(),
+          child: Text('Close'),
+        ),
+        FlatButton(
+          onPressed: () => Navigator.of(context).pushReplacement(
+              MaterialPageRoute(
+                  builder: (context) => ProxmoxGeneralSettingsForm())),
+          child: Text('Settings'),
+        )
+      ],
+    );
+  }
+}
diff --git a/lib/proxmox_login_manager.dart b/lib/proxmox_login_manager.dart
new file mode 100644 (file)
index 0000000..8b09583
--- /dev/null
@@ -0,0 +1,4 @@
+library proxmox_login_manager;
+
+export 'proxmox_login_selector.dart';
+export 'proxmox_login_model.dart';
diff --git a/lib/proxmox_login_model.dart b/lib/proxmox_login_model.dart
new file mode 100644 (file)
index 0000000..f7ceccb
--- /dev/null
@@ -0,0 +1,106 @@
+import 'package:built_collection/built_collection.dart';
+import 'package:built_value/built_value.dart';
+import 'package:built_value/serializer.dart';
+import 'package:proxmox_login_manager/proxmox_general_settings_model.dart';
+import 'package:proxmox_login_manager/serializers.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'dart:convert';
+import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
+    as proxclient;
+part 'proxmox_login_model.g.dart';
+
+abstract class ProxmoxLoginStorage
+    implements Built<ProxmoxLoginStorage, ProxmoxLoginStorageBuilder> {
+  BuiltList<ProxmoxLoginModel> get logins;
+
+  ProxmoxLoginStorage._();
+  factory ProxmoxLoginStorage(
+          [void Function(ProxmoxLoginStorageBuilder) updates]) =
+      _$ProxmoxLoginStorage;
+
+  Object toJson() {
+    return serializers.serializeWith(ProxmoxLoginStorage.serializer, this);
+  }
+
+  static ProxmoxLoginStorage fromJson(Object json) {
+    return serializers.deserializeWith(ProxmoxLoginStorage.serializer, json);
+  }
+
+  static Serializer<ProxmoxLoginStorage> get serializer =>
+      _$proxmoxLoginStorageSerializer;
+
+  static Future<ProxmoxLoginStorage> fromLocalStorage() async {
+    final SharedPreferences prefs = await SharedPreferences.getInstance();
+    if (prefs.containsKey('ProxmoxLoginList')) {
+      final decodedJson = json.decode(prefs.getString('ProxmoxLoginList'));
+      return fromJson(decodedJson);
+    }
+    return ProxmoxLoginStorage();
+  }
+
+  Future<void> saveToDisk() async {
+    final SharedPreferences prefs = await SharedPreferences.getInstance();
+    prefs.setString('ProxmoxLoginList', json.encode(toJson()));
+  }
+
+  Future<proxclient.ProxmoxApiClient> recoverLatestSession() async {
+    final latestSession = logins.singleWhere((e) => e.ticket.isNotEmpty);
+    final settings = await ProxmoxGeneralSettingsModel.fromLocalStorage();
+    final apiClient = await proxclient.authenticate(latestSession.fullUsername,
+        latestSession.ticket, latestSession.origin, settings.sslValidation);
+    return apiClient;
+  }
+
+  Future<void> invalidateAllSessions() async {
+    final invalidatedList =
+        logins.map((e) => e.rebuild((login) => login..ticket = ''));
+    await rebuild((e) => e.logins.replace(invalidatedList)).saveToDisk();
+  }
+}
+
+abstract class ProxmoxLoginModel
+    implements Built<ProxmoxLoginModel, ProxmoxLoginModelBuilder> {
+  Uri get origin;
+
+  String get username;
+
+  String get realm;
+
+  ProxmoxProductType get productType;
+
+  String get ticket;
+
+  /// The username with the corresponding realm e.g. root@pam
+  String get fullUsername => '$username@$realm';
+
+  ProxmoxLoginModel._();
+
+  factory ProxmoxLoginModel([void Function(ProxmoxLoginModelBuilder) updates]) =
+      _$ProxmoxLoginModel;
+
+  Map<String, dynamic> toJson() {
+    return serializers.serializeWith(ProxmoxLoginModel.serializer, this);
+  }
+
+  static ProxmoxLoginModel fromJson(Map<String, dynamic> json) {
+    return serializers.deserializeWith(ProxmoxLoginModel.serializer, json);
+  }
+
+  static Serializer<ProxmoxLoginModel> get serializer =>
+      _$proxmoxLoginModelSerializer;
+}
+
+class ProxmoxProductType extends EnumClass {
+  static const ProxmoxProductType pve = _$pve;
+  static const ProxmoxProductType pmg = _$pmg;
+  static const ProxmoxProductType pbs = _$pbs;
+
+  const ProxmoxProductType._(String name) : super(name);
+
+  static BuiltSet<ProxmoxProductType> get values => _$ProxmoxProductTypeValues;
+  static ProxmoxProductType valueOf(String name) =>
+      _$ProxmoxProductTypeValueOf(name);
+
+  static Serializer<ProxmoxProductType> get serializer =>
+      _$proxmoxProductTypeSerializer;
+}
diff --git a/lib/proxmox_login_selector.dart b/lib/proxmox_login_selector.dart
new file mode 100644 (file)
index 0000000..8cd02ff
--- /dev/null
@@ -0,0 +1,96 @@
+import 'package:flutter/material.dart';
+import 'package:proxmox_login_manager/proxmox_general_settings_form.dart';
+import 'package:proxmox_login_manager/proxmox_login_form.dart';
+import 'package:proxmox_login_manager/proxmox_login_model.dart';
+import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart'
+    as proxclient;
+
+typedef OnLoginCallback = Function(proxclient.ProxmoxApiClient client);
+
+class ProxmoxLoginSelector extends StatefulWidget {
+  final OnLoginCallback onLogin;
+
+  const ProxmoxLoginSelector({Key key, this.onLogin}) : super(key: key);
+
+  @override
+  _ProxmoxLoginSelectorState createState() => _ProxmoxLoginSelectorState();
+}
+
+class _ProxmoxLoginSelectorState extends State<ProxmoxLoginSelector> {
+  Future<ProxmoxLoginStorage> loginStorage;
+  @override
+  void initState() {
+    super.initState();
+    loginStorage = ProxmoxLoginStorage.fromLocalStorage();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return SafeArea(
+      child: Scaffold(
+        appBar: AppBar(
+          title: Text(
+            'PROXMOX',
+            style: TextStyle(
+              fontFamily: 'Proxmox',
+              fontSize: 28,
+            ),
+          ),
+          actions: [
+            IconButton(
+                icon: Icon(Icons.settings),
+                onPressed: () {
+                  Navigator.of(context).push(MaterialPageRoute(
+                    builder: (context) => ProxmoxGeneralSettingsForm(),
+                  ));
+                })
+          ],
+        ),
+        body: FutureBuilder<ProxmoxLoginStorage>(
+            future: loginStorage,
+            builder: (context, snapshot) {
+              return ListView(
+                children: snapshot.data?.logins
+                        ?.map((l) => ListTile(
+                              title: Text(l.origin.host),
+                              subtitle: Text(l.fullUsername),
+                              trailing: Icon(Icons.navigate_next),
+                              onTap: () => _login(user: l),
+                              onLongPress: () {
+                                snapshot.data
+                                    .rebuild((b) => b.logins.remove(l))
+                                    .saveToDisk();
+                                refreshFromStorage();
+                              },
+                            ))
+                        ?.toList() ??
+                    [],
+              );
+            }),
+        floatingActionButton: FloatingActionButton.extended(
+          onPressed: () => _login(isCreate: true),
+          label: Text('Add'),
+          icon: Icon(Icons.account_circle),
+        ),
+      ),
+    );
+  }
+
+  Future<void> _login({ProxmoxLoginModel user, bool isCreate = false}) async {
+    final client = await Navigator.of(context).push(MaterialPageRoute(
+        builder: (context) => ProxmoxLoginPage(
+              userModel: user,
+              isCreate: isCreate,
+            )));
+    refreshFromStorage();
+    if (client != null) {
+      widget.onLogin(client);
+    }
+  }
+
+  void refreshFromStorage() {
+    setState(() {
+      loginStorage = ProxmoxLoginStorage.fromLocalStorage();
+    });
+  }
+}
diff --git a/lib/proxmox_tfa_form.dart b/lib/proxmox_tfa_form.dart
new file mode 100644 (file)
index 0000000..71ebddd
--- /dev/null
@@ -0,0 +1,132 @@
+import 'package:flutter/material.dart';
+import 'package:proxmox_dart_api_client/proxmox_dart_api_client.dart';
+import 'package:proxmox_login_manager/proxmox_login_form.dart';
+
+class ProxmoxTfaForm extends StatefulWidget {
+  final ProxmoxApiClient apiClient;
+
+  const ProxmoxTfaForm({Key key, this.apiClient}) : super(key: key);
+
+  @override
+  _ProxmoxTfaFormState createState() => _ProxmoxTfaFormState();
+}
+
+class _ProxmoxTfaFormState extends State<ProxmoxTfaForm> {
+  final TextEditingController _codeController = TextEditingController();
+  bool _isLoading = false;
+  @override
+  Widget build(BuildContext context) {
+    return Theme(
+      data: ThemeData.dark().copyWith(accentColor: Color(0xFFE47225)),
+      child: Material(
+        color: Theme.of(context).primaryColor,
+        child: Stack(
+          alignment: Alignment.center,
+          children: [
+            SingleChildScrollView(
+              child: ConstrainedBox(
+                constraints: BoxConstraints.tightFor(
+                    height: MediaQuery.of(context).size.height),
+                child: Padding(
+                  padding: const EdgeInsets.all(8.0),
+                  child: Column(
+                    mainAxisAlignment: MainAxisAlignment.start,
+                    crossAxisAlignment: CrossAxisAlignment.center,
+                    children: <Widget>[
+                      Padding(
+                        padding: const EdgeInsets.fromLTRB(0, 100.0, 0, 30.0),
+                        child: Icon(
+                          Icons.lock,
+                          size: 48,
+                        ),
+                      ),
+                      Text(
+                        'Verify',
+                        style: TextStyle(
+                            fontSize: 36,
+                            color: Colors.white,
+                            fontWeight: FontWeight.bold),
+                      ),
+                      Text(
+                        'Check your second factor provider',
+                        style: TextStyle(
+                            color: Colors.white38, fontWeight: FontWeight.bold),
+                      ),
+                      Padding(
+                        padding: const EdgeInsets.fromLTRB(0, 50.0, 0, 8.0),
+                        child: Container(
+                          width: 150,
+                          child: TextField(
+                              controller: _codeController,
+                              textAlign: TextAlign.center,
+                              decoration: InputDecoration(labelText: 'Code'),
+                              autofocus: true,
+                              onSubmitted: (value) => _submitTfaCode()),
+                        ),
+                      ),
+                      Expanded(
+                        child: Align(
+                          alignment: Alignment.bottomCenter,
+                          child: Container(
+                            width: MediaQuery.of(context).size.width,
+                            child: FlatButton(
+                              onPressed: () => _submitTfaCode(),
+                              color: Color(0xFFE47225),
+                              child: Text('Continue'),
+                              disabledColor: Colors.grey,
+                            ),
+                          ),
+                        ),
+                      ),
+                    ],
+                  ),
+                ),
+              ),
+            ),
+            if (_isLoading)
+              ProxmoxProgressOverlay(
+                message: 'Verify One-Time password...',
+              )
+          ],
+        ),
+      ),
+    );
+  }
+
+  Future<void> _submitTfaCode() async {
+    setState(() {
+      _isLoading = true;
+    });
+    try {
+      final client =
+          await widget.apiClient.finishTfaChallenge(_codeController.text);
+      Navigator.of(context).pop(client);
+    } on ProxmoxApiException catch (e) {
+      showDialog(
+        context: context,
+        builder: (context) => ProxmoxApiErrorDialog(
+          exception: e,
+        ),
+      );
+    } catch (e, trace) {
+      print(e);
+      print(trace);
+      showDialog(
+        context: context,
+        builder: (context) => AlertDialog(
+          title: Text('Connection error'),
+          content: Text('Could not establish connection.'),
+          actions: [
+            FlatButton(
+              onPressed: () => Navigator.of(context).pop(),
+              child: Text('Close'),
+            ),
+          ],
+        ),
+      );
+    }
+    setState(() {
+      _isLoading = false;
+    });
+  }
+}
diff --git a/lib/serializers.dart b/lib/serializers.dart
new file mode 100644 (file)
index 0000000..80f375d
--- /dev/null
@@ -0,0 +1,8 @@
+import 'package:built_value/serializer.dart';
+import 'package:built_collection/built_collection.dart';
+import 'package:proxmox_login_manager/proxmox_general_settings_model.dart';
+import 'package:proxmox_login_manager/proxmox_login_model.dart';
+part 'serializers.g.dart';
+
+@SerializersFor([ProxmoxLoginStorage, ProxmoxGeneralSettingsModel])
+final Serializers serializers = (_$serializers.toBuilder()).build();
diff --git a/pubspec.lock b/pubspec.lock
new file mode 100644 (file)
index 0000000..3462be0
--- /dev/null
@@ -0,0 +1,579 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  _fe_analyzer_shared:
+    dependency: transitive
+    description:
+      name: _fe_analyzer_shared
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "5.0.0"
+  analyzer:
+    dependency: transitive
+    description:
+      name: analyzer
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.39.12"
+  args:
+    dependency: transitive
+    description:
+      name: args
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.6.0"
+  async:
+    dependency: transitive
+    description:
+      name: async
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.4.1"
+  boolean_selector:
+    dependency: transitive
+    description:
+      name: boolean_selector
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.0"
+  build:
+    dependency: transitive
+    description:
+      name: build
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.3.0"
+  build_config:
+    dependency: transitive
+    description:
+      name: build_config
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.4.2"
+  build_daemon:
+    dependency: transitive
+    description:
+      name: build_daemon
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.1.4"
+  build_resolvers:
+    dependency: transitive
+    description:
+      name: build_resolvers
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.3.10"
+  build_runner:
+    dependency: "direct dev"
+    description:
+      name: build_runner
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.10.0"
+  build_runner_core:
+    dependency: transitive
+    description:
+      name: build_runner_core
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "5.2.0"
+  built_collection:
+    dependency: "direct main"
+    description:
+      name: built_collection
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "4.3.2"
+  built_value:
+    dependency: "direct main"
+    description:
+      name: built_value
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "7.1.0"
+  built_value_generator:
+    dependency: "direct dev"
+    description:
+      name: built_value_generator
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "7.1.0"
+  characters:
+    dependency: transitive
+    description:
+      name: characters
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.0"
+  charcode:
+    dependency: transitive
+    description:
+      name: charcode
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.1.3"
+  checked_yaml:
+    dependency: transitive
+    description:
+      name: checked_yaml
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.2"
+  clock:
+    dependency: transitive
+    description:
+      name: clock
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.1"
+  code_builder:
+    dependency: transitive
+    description:
+      name: code_builder
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "3.4.0"
+  collection:
+    dependency: transitive
+    description:
+      name: collection
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.14.12"
+  convert:
+    dependency: transitive
+    description:
+      name: convert
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.1.1"
+  crypto:
+    dependency: transitive
+    description:
+      name: crypto
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.1.5"
+  csslib:
+    dependency: transitive
+    description:
+      name: csslib
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.16.1"
+  dart_style:
+    dependency: transitive
+    description:
+      name: dart_style
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.3.6"
+  fake_async:
+    dependency: transitive
+    description:
+      name: fake_async
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.1.0"
+  file:
+    dependency: transitive
+    description:
+      name: file
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "5.2.1"
+  fixnum:
+    dependency: transitive
+    description:
+      name: fixnum
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.10.11"
+  flutter:
+    dependency: "direct main"
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  flutter_test:
+    dependency: "direct dev"
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  flutter_web_plugins:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  glob:
+    dependency: transitive
+    description:
+      name: glob
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.2.0"
+  graphs:
+    dependency: transitive
+    description:
+      name: graphs
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.2.0"
+  html:
+    dependency: transitive
+    description:
+      name: html
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.14.0+3"
+  http:
+    dependency: transitive
+    description:
+      name: http
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.12.1"
+  http_multi_server:
+    dependency: transitive
+    description:
+      name: http_multi_server
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.2.0"
+  http_parser:
+    dependency: transitive
+    description:
+      name: http_parser
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "3.1.4"
+  intl:
+    dependency: transitive
+    description:
+      name: intl
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.16.1"
+  io:
+    dependency: transitive
+    description:
+      name: io
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.3.4"
+  js:
+    dependency: transitive
+    description:
+      name: js
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.6.2"
+  json_annotation:
+    dependency: transitive
+    description:
+      name: json_annotation
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "3.0.1"
+  logging:
+    dependency: transitive
+    description:
+      name: logging
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.11.4"
+  matcher:
+    dependency: transitive
+    description:
+      name: matcher
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.12.6"
+  meta:
+    dependency: transitive
+    description:
+      name: meta
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.1.8"
+  mime:
+    dependency: transitive
+    description:
+      name: mime
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.9.6+3"
+  node_interop:
+    dependency: transitive
+    description:
+      name: node_interop
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.1.1"
+  node_io:
+    dependency: transitive
+    description:
+      name: node_io
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.1.1"
+  package_config:
+    dependency: transitive
+    description:
+      name: package_config
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.9.3"
+  path:
+    dependency: transitive
+    description:
+      name: path
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.7.0"
+  path_provider_linux:
+    dependency: transitive
+    description:
+      name: path_provider_linux
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.0.1+2"
+  path_provider_platform_interface:
+    dependency: transitive
+    description:
+      name: path_provider_platform_interface
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.2"
+  pedantic:
+    dependency: transitive
+    description:
+      name: pedantic
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.9.0"
+  platform:
+    dependency: transitive
+    description:
+      name: platform
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.2.1"
+  plugin_platform_interface:
+    dependency: transitive
+    description:
+      name: plugin_platform_interface
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.2"
+  pool:
+    dependency: transitive
+    description:
+      name: pool
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.4.0"
+  process:
+    dependency: transitive
+    description:
+      name: process
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "3.0.13"
+  proxmox_dart_api_client:
+    dependency: "direct main"
+    description:
+      path: "../proxmox_dart_api_client"
+      relative: true
+    source: path
+    version: "0.0.0"
+  pub_semver:
+    dependency: transitive
+    description:
+      name: pub_semver
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.4.4"
+  pubspec_parse:
+    dependency: transitive
+    description:
+      name: pubspec_parse
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.1.5"
+  quiver:
+    dependency: transitive
+    description:
+      name: quiver
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.1.3"
+  retry:
+    dependency: transitive
+    description:
+      name: retry
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "3.0.1"
+  shared_preferences:
+    dependency: "direct main"
+    description:
+      name: shared_preferences
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.5.8"
+  shared_preferences_linux:
+    dependency: transitive
+    description:
+      name: shared_preferences_linux
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.0.2+1"
+  shared_preferences_macos:
+    dependency: transitive
+    description:
+      name: shared_preferences_macos
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.0.1+10"
+  shared_preferences_platform_interface:
+    dependency: transitive
+    description:
+      name: shared_preferences_platform_interface
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.4"
+  shared_preferences_web:
+    dependency: transitive
+    description:
+      name: shared_preferences_web
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.1.2+7"
+  shelf:
+    dependency: transitive
+    description:
+      name: shelf
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.7.7"
+  shelf_web_socket:
+    dependency: transitive
+    description:
+      name: shelf_web_socket
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.2.3"
+  sky_engine:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.99"
+  source_gen:
+    dependency: transitive
+    description:
+      name: source_gen
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.9.6"
+  source_span:
+    dependency: transitive
+    description:
+      name: source_span
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.7.0"
+  stack_trace:
+    dependency: transitive
+    description:
+      name: stack_trace
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.9.3"
+  stream_channel:
+    dependency: transitive
+    description:
+      name: stream_channel
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.0"
+  stream_transform:
+    dependency: transitive
+    description:
+      name: stream_transform
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.2.0"
+  string_scanner:
+    dependency: transitive
+    description:
+      name: string_scanner
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.0.5"
+  term_glyph:
+    dependency: transitive
+    description:
+      name: term_glyph
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.1.0"
+  test_api:
+    dependency: transitive
+    description:
+      name: test_api
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.2.16"
+  timing:
+    dependency: transitive
+    description:
+      name: timing
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.1.1+2"
+  typed_data:
+    dependency: transitive
+    description:
+      name: typed_data
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.1.6"
+  vector_math:
+    dependency: transitive
+    description:
+      name: vector_math
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.0.8"
+  watcher:
+    dependency: transitive
+    description:
+      name: watcher
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.9.7+15"
+  web_socket_channel:
+    dependency: transitive
+    description:
+      name: web_socket_channel
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "1.1.0"
+  xdg_directories:
+    dependency: transitive
+    description:
+      name: xdg_directories
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "0.1.0"
+  yaml:
+    dependency: transitive
+    description:
+      name: yaml
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "2.2.1"
+sdks:
+  dart: ">=2.9.0-14.0.dev <3.0.0"
+  flutter: ">=1.12.13+hotfix.5 <2.0.0"
diff --git a/pubspec.yaml b/pubspec.yaml
new file mode 100644 (file)
index 0000000..4ff1f2f
--- /dev/null
@@ -0,0 +1,55 @@
+name: proxmox_login_manager
+description: A new Flutter package project.
+version: 0.0.1
+author:
+
+environment:
+  sdk: ">=2.7.0 <3.0.0"
+
+dependencies:
+  flutter:
+    sdk: flutter
+  shared_preferences: ^0.5.8
+  built_value: ^7.1.0
+  built_collection: ^4.3.2
+  proxmox_dart_api_client:
+    path: ../proxmox_dart_api_client
+
+
+
+dev_dependencies:
+  flutter_test:
+    sdk: flutter
+  build_runner: ^1.10.0
+  built_value_generator: ^7.1.0
+
+
+# For information on the generic Dart part of this file, see the
+# following page: https://dart.dev/tools/pub/pubspec
+
+# The following section is specific to Flutter.
+flutter:
+
+  # To add assets to your package, add an assets section, like this:
+  # assets:
+  #   - images/a_dot_burr.jpeg
+  #   - images/a_dot_ham.jpeg
+  #
+  # For details regarding assets in packages, see
+  # https://flutter.dev/assets-and-images/#from-packages
+  #
+  # An image asset can refer to one or more resolution-specific "variants", see
+  # https://flutter.dev/assets-and-images/#resolution-aware.
+
+  # To add custom fonts to your package, add a fonts section here,
+  # in this "flutter" section. Each entry in this list should have a
+  # "family" key with the font family name, and a "fonts" key with a
+  # list giving the asset and other descriptors for the font. For
+  # example:
+  fonts:
+    - family: Proxmox
+      fonts:
+        - asset: assets/fonts/Proxmox.ttf
+  #
+  # For details regarding fonts in packages, see
+  # https://flutter.dev/custom-fonts/#from-packages
diff --git a/test/proxmox_login_manager_test.dart b/test/proxmox_login_manager_test.dart
new file mode 100644 (file)
index 0000000..c45b514
--- /dev/null
@@ -0,0 +1,5 @@
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:proxmox_login_manager/proxmox_login_manager.dart';
+
+void main() {}