diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 02111c89..4e3e3718 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,9 +18,6 @@ env: INVENTREE_ADMIN_USER: testuser INVENTREE_ADMIN_PASSWORD: testpassword INVENTREE_ADMIN_EMAIL: test@test.com - INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345 - INVENTREE_PYTHON_TEST_USERNAME: testuser - INVENTREE_PYTHON_TEST_PASSWORD: testpassword jobs: test: @@ -64,7 +61,7 @@ jobs: invoke install invoke migrate invoke import-fixtures - invoke server -a 127.0.0.1:12345 & + invoke server -a 127.0.0.1:8000 & invoke wait sleep 30 - name: Unit Tests diff --git a/assets/release_notes.md b/assets/release_notes.md index 31e37f51..1bdcb63f 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -2,6 +2,9 @@ --- - Add ability to scan in received items using supplier barcodes +- Store API token, rather than username:password +- Ensure that user will lose access if token is revoked by server + ### 0.12.8 - September 2023 --- diff --git a/lib/api.dart b/lib/api.dart index 6ea78805..b8a2d7b1 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -192,16 +192,13 @@ class InvenTreeAPI { bool _strictHttps = false; // Endpoint for requesting an API token - static const _URL_GET_TOKEN = "user/token/"; - - static const _URL_GET_ROLES = "user/roles/"; - - // Base URL for InvenTree API e.g. http://192.168.120.10:8000 - String _BASE_URL = ""; + static const _URL_TOKEN = "user/token/"; + static const _URL_ROLES = "user/roles/"; + static const _URL_ME = "user/me/"; // Accessors for various url endpoints String get baseUrl { - String url = _BASE_URL; + String url = profile?.server ?? ""; if (!url.endsWith("/")) { url += "/"; @@ -242,21 +239,22 @@ class InvenTreeAPI { // Available user roles (permissions) are loaded when connecting to the server Map roles = {}; - // Authentication token (initially empty, must be requested) - String _token = ""; + // Profile authentication token + String get token => profile?.token ?? ""; + + bool get hasToken => token.isNotEmpty; String? get serverAddress { return profile?.server; } - bool get hasToken => _token.isNotEmpty; - /* * Check server connection and display messages if not connected. * Useful as a precursor check before performing operations. */ bool checkConnection() { - // Firstly, is the server connected? + + // Is the server connected? if (!isConnected()) { showSnackIcon( @@ -272,16 +270,20 @@ class InvenTreeAPI { return true; } - // Server instance information - String instance = ""; + // Map of user information + Map userInfo = {}; - // Server version information - String _version = ""; + String get username => (userInfo["username"] ?? "") as String; - // API version of the connected server - int _apiVersion = 1; + // Map of server information + Map serverInfo = {}; - int get apiVersion => _apiVersion; + String get serverInstance => (serverInfo["instance"] ?? "") as String; + String get serverVersion => (serverInfo["version"] ?? "") as String; + int get apiVersion => (serverInfo["apiVersion"] ?? 1) as int; + + // Plugins enabled at API v34 and above + bool get pluginsEnabled => apiVersion >= 34 && (serverInfo["plugins_enabled"] ?? false) as bool; // API endpoint for receiving purchase order line items was introduced in v12 bool get supportsPoReceive => apiVersion >= 12; @@ -330,13 +332,6 @@ class InvenTreeAPI { bool get supportsBarcodePOReceiveEndpoint => isConnected() && apiVersion >= 139; - // Are plugins enabled on the server? - bool _pluginsEnabled = false; - - // True plugin support requires API v34 or newer - // Returns True only if the server API version is new enough, and plugins are enabled - bool pluginsEnabled() => apiVersion >= 34 && _pluginsEnabled; - // Cached list of plugins (refreshed when we connect to the server) List _plugins = []; @@ -363,9 +358,6 @@ class InvenTreeAPI { // Test if the provided plugin mixin is supported by any active plugins bool supportsMixin(String mixin) => getPlugins(mixin: mixin).isNotEmpty; - // Getter for server version information - String get version => _version; - // Connection status flag - set once connection has been validated bool _connected = false; @@ -379,33 +371,68 @@ class InvenTreeAPI { return !isConnected() && _connecting; } - /* - * Connect to the remote InvenTree server: - * - * - Check that the InvenTree server exists - * - Request user token from the server - * - Request user roles from the server - */ - Future _connect() async { - if (profile == null) return false; + /* + * Perform the required login steps, in sequence. + * Internal function, called by connectToServer() + * + * Performs the following steps: + * + * 1. Check the api/ endpoint to see if the sever exists + * 2. If no token available, perform user authentication + * 2. Check the api/user/me/ endpoint to see if the user is authenticated + * 3. If not authenticated, purge token, and exit + * 4. Request user roles + * 5. Request information on available plugins + */ + Future _connectToServer() async { + + if (!await _checkServer()) { + return false; + } + + if (!hasToken) { + return false; + } + + if (!await _checkAuth()) { + showServerError(_URL_ME, L10().serverNotConnected, L10().serverAuthenticationError); + + // Invalidate the token + if (profile != null) { + profile!.token = ""; + await UserProfileDBManager().updateProfile(profile!); + } + + return false; + } + + if (!await _fetchRoles()) { + return false; + } + + if (!await _fetchPlugins()) { + return false; + } + + // Finally, connected + return true; + } + + + /* + * Check that the remote server is available. + * Ping the api/ endpoint, which does not require user authentication + */ + Future _checkServer() async { String address = profile?.server ?? ""; - String username = profile?.username ?? ""; - String password = profile?.password ?? ""; - address = address.trim(); - username = username.trim(); - password = password.trim(); - - // Cache the "strictHttps" setting, so we can use it later without async requirement - _strictHttps = await InvenTreeSettingsManager().getValue(INV_STRICT_HTTPS, false) as bool; - - if (address.isEmpty || username.isEmpty || password.isEmpty) { + if (address.isEmpty) { showSnackIcon( - L10().incompleteDetails, - icon: FontAwesomeIcons.circleExclamation, - success: false + L10().incompleteDetails, + icon: FontAwesomeIcons.circleExclamation, + success: false ); return false; } @@ -414,27 +441,24 @@ class InvenTreeAPI { address = address + "/"; } - _BASE_URL = address; + // Cache the "strictHttps" setting, so we can use it later without async requirement + _strictHttps = await InvenTreeSettingsManager().getValue(INV_STRICT_HTTPS, false) as bool; - // Clear the list of available plugins - _plugins.clear(); + debug("Connecting to ${apiUrl}"); - debug("Connecting to ${apiUrl} -> username=${username}"); - - APIResponse response; - - response = await get("", expectedStatusCode: 200); + APIResponse response = await get("", expectedStatusCode: 200); if (!response.successful()) { + debug("Server returned invalid response: ${response.statusCode}"); showStatusCodeError(apiUrl, response.statusCode, details: response.data.toString()); return false; } - var data = response.asMap(); + Map _data = response.asMap(); - // We expect certain response from the server - if (!data.containsKey("server") || !data.containsKey("version") || !data.containsKey("instance")) { + serverInfo = {..._data}; + if (serverVersion.isEmpty) { showServerError( apiUrl, L10().missingData, @@ -444,17 +468,9 @@ class InvenTreeAPI { return false; } - // Record server information - _version = (data["version"] ?? "") as String; - instance = (data["instance"] ?? "") as String; + if (apiVersion < _minApiVersion) { - // Default API version is 1 if not provided - _apiVersion = (data["apiVersion"] ?? 1) as int; - _pluginsEnabled = (data["plugins_enabled"] ?? false) as bool; - - if (_apiVersion < _minApiVersion) { - - String message = L10().serverApiVersion + ": ${_apiVersion}"; + String message = L10().serverApiVersion + ": ${apiVersion}"; message += "\n"; message += L10().serverApiRequired + ": ${_minApiVersion}"; @@ -472,18 +488,86 @@ class InvenTreeAPI { return false; } - /** - * Request user token information from the server - * This is the stage that we check username:password credentials! - */ - // Clear the existing token value - _token = ""; + // At this point, we have a server which is responding + return true; + } - response = await get(_URL_GET_TOKEN); + + /* + * Check that the user is authenticated + * Fetch the user information + */ + Future _checkAuth() async { + debug("Checking user auth @ ${_URL_ME}"); + + userInfo.clear(); + + final response = await get(_URL_ME); + + if (response.successful() && response.statusCode == 200) { + userInfo = response.asMap(); + return true; + } else { + debug("Auth request failed: Server returned status ${response.statusCode}"); + if (response.data != null) { + debug("Server response: ${response.data.toString()}"); + } + + return false; + } + } + + /* + * Fetch a token from the server, + * with a temporary authentication header + */ + Future fetchToken(UserProfile userProfile, String username, String password) async { + + debug("Fetching user token from ${userProfile.server}"); + + profile = userProfile; + + // Form a name to request the token with + String platform_name = "inventree-mobile-app"; + + final deviceInfo = await getDeviceInfo(); + + if (Platform.isAndroid) { + platform_name += "-android"; + } else if (Platform.isIOS) { + platform_name += "-ios"; + } else if (Platform.isMacOS) { + platform_name += "-macos"; + } else if (Platform.isLinux) { + platform_name += "-linux"; + } else if (Platform.isWindows) { + platform_name += "-windows"; + } + + if (deviceInfo.containsKey("name")) { + platform_name += "-" + (deviceInfo["name"] as String); + } + + if (deviceInfo.containsKey("model")) { + platform_name += "-" + (deviceInfo["model"] as String); + } + + if (deviceInfo.containsKey("systemVersion")) { + platform_name += "-" + (deviceInfo["systemVersion"] as String); + } + + // Construct auth header from username and password + String authHeader = "Basic " + base64Encode(utf8.encode("${username}:${password}")); + + // Perform request to get a token + final response = await get( + _URL_TOKEN, + params: { "name": platform_name}, + headers: { HttpHeaders.authorizationHeader: authHeader} + ); // Invalid response if (!response.successful()) { - switch (response.statusCode) { case 401: case 403: @@ -500,67 +584,29 @@ class InvenTreeAPI { debug("Token request failed: STATUS ${response.statusCode}"); - return false; + if (response.data != null) { + debug("Response data: ${response.data.toString()}"); + } } - data = response.asMap(); + final data = response.asMap(); if (!data.containsKey("token")) { - showServerError( - apiUrl, - L10().tokenMissing, - L10().tokenMissingFromResponse, - ); - - return false; - } - - // Return the received token - _token = (data["token"] ?? "") as String; - - debug("Received token from server"); - - bool result = false; - - // Request user role information (async) - result = await getUserRoles(); - - if (!result) { showServerError( apiUrl, - L10().serverError, - L10().errorUserRoles, + L10().tokenMissing, + L10().tokenMissingFromResponse, ); - - return false; } - // Request plugin information (async) - result = await getPluginInformation(); + // Save the token to the user profile + userProfile.token = (data["token"] ?? "") as String; - if (!result) { - showServerError( - apiUrl, - L10().serverError, - L10().errorPluginInfo - ); + debug("Received token from server: ${userProfile.token}"); - return false; - } - - // Ok, probably pretty good... - - if (_notification_timer == null) { - debug("starting notification timer"); - _notification_timer = Timer.periodic( - Duration(seconds: 5), - (timer) { - _refreshNotifications(); - }); - } - - return true; + await UserProfileDBManager().updateProfile(userProfile); + return response; } void disconnectFromServer() { @@ -568,24 +614,25 @@ class InvenTreeAPI { _connected = false; _connecting = false; - _token = ""; profile = null; // Clear received settings _globalSettings.clear(); _userSettings.clear(); + serverInfo.clear(); _connectionStatusChanged(); } - // Public facing connection function - Future connectToServer() async { + + /* Public facing connection function. + */ + Future connectToServer(UserProfile prf) async { // Ensure server is first disconnected disconnectFromServer(); - // Load selected profile - profile = await UserProfileDBManager().getSelectedProfile(); + profile = prf; if (profile == null) { showSnackIcon( @@ -596,12 +643,14 @@ class InvenTreeAPI { return false; } - _connecting = true; + // Cancel notification timer + _notification_timer?.cancel(); + _connecting = true; _connectionStatusChanged(); - _connected = await _connect(); - + // Perform the actual connection routine + _connected = await _connectToServer(); _connecting = false; if (_connected) { @@ -610,6 +659,15 @@ class InvenTreeAPI { icon: FontAwesomeIcons.server, success: true, ); + + if (_notification_timer == null) { + debug("starting notification timer"); + _notification_timer = Timer.periodic( + Duration(seconds: 5), + (timer) { + _refreshNotifications(); + }); + } } _connectionStatusChanged(); @@ -620,18 +678,13 @@ class InvenTreeAPI { /* * Request the user roles (permissions) from the InvenTree server */ - Future getUserRoles() async { + Future _fetchRoles() async { roles.clear(); debug("API: Requesting user role data"); - // Next we request the permissions assigned to the current user - // Note: 2021-02-27 this "roles" feature for the API was just introduced. - // Any "older" version of the server allows any API method for any logged in user! - // We will return immediately, but request the user roles in the background - - final response = await get(_URL_GET_ROLES, expectedStatusCode: 200); + final response = await get(_URL_ROLES, expectedStatusCode: 200); if (!response.successful()) { return false; @@ -645,12 +698,17 @@ class InvenTreeAPI { return true; } else { + showServerError( + apiUrl, + L10().serverError, + L10().errorUserRoles, + ); return false; } } // Request plugin information from the server - Future getPluginInformation() async { + Future _fetchPlugins() async { _plugins.clear(); @@ -690,7 +748,7 @@ class InvenTreeAPI { if (roles[role] == null) { debug("checkPermission - role '$role' is null!"); - return true; + return false; } try { @@ -1045,7 +1103,14 @@ class InvenTreeAPI { * @param method is the HTTP method e.g. "POST" / "PATCH" / "GET" etc; * @param params is the request parameters */ - Future apiRequest(String url, String method, {Map urlParams = const {}}) async { + Future apiRequest( + String url, + String method, + { + Map urlParams = const {}, + Map headers = const {}, + } + ) async { var _url = makeApiUrl(url); @@ -1085,11 +1150,16 @@ class InvenTreeAPI { try { _request = await client.openUrl(method, _uri).timeout(Duration(seconds: 10)); - // Set headers + // Default headers defaultHeaders().forEach((key, value) { _request?.headers.set(key, value); }); + // Custom headers + headers.forEach((key, value) { + _request?.headers.set(key, value); + }); + return _request; } on SocketException catch (error) { debug("SocketException at ${url}: ${error.toString()}"); @@ -1262,12 +1332,13 @@ class InvenTreeAPI { * Perform a HTTP GET request * Returns a json object (or null if did not complete) */ - Future get(String url, {Map params = const {}, int? expectedStatusCode=200}) async { + Future get(String url, {Map params = const {}, Map headers = const {}, int? expectedStatusCode=200}) async { HttpClientRequest? request = await apiRequest( url, "GET", urlParams: params, + headers: headers, ); @@ -1334,7 +1405,10 @@ class InvenTreeAPI { Map defaultHeaders() { Map headers = {}; - headers[HttpHeaders.authorizationHeader] = _authorizationHeader(); + if (hasToken) { + headers[HttpHeaders.authorizationHeader] = _authorizationHeader(); + } + headers[HttpHeaders.acceptHeader] = "application/json"; headers[HttpHeaders.contentTypeHeader] = "application/json"; headers[HttpHeaders.acceptLanguageHeader] = currentLocale; @@ -1342,11 +1416,10 @@ class InvenTreeAPI { return headers; } + // Construct a token authorization header String _authorizationHeader() { - if (_token.isNotEmpty) { - return "Token $_token"; - } else if (profile != null) { - return "Basic " + base64Encode(utf8.encode("${profile?.username}:${profile?.password}")); + if (token.isNotEmpty) { + return "Token ${token}"; } else { return ""; } @@ -1579,3 +1652,5 @@ class InvenTreeAPI { }); } } + + diff --git a/lib/inventree/purchase_order.dart b/lib/inventree/purchase_order.dart index 199bc15e..defb9f8a 100644 --- a/lib/inventree/purchase_order.dart +++ b/lib/inventree/purchase_order.dart @@ -131,7 +131,17 @@ class InvenTreePurchaseOrder extends InvenTreeModel { } } - String get totalPriceCurrency => getString("total_price_currency"); + // Return the currency for this order + // Note that the nomenclature in the API changed at some point + String get totalPriceCurrency { + if (jsondata.containsKey("order_currency")) { + return getString("order_currency"); + } else if (jsondata.containsKey("total_price_currency")) { + return getString("total_price_currency"); + } else { + return ""; + } + } Future> getLineItems() async { diff --git a/lib/inventree/sentry.dart b/lib/inventree/sentry.dart index 1d82ff01..4ca9cf41 100644 --- a/lib/inventree/sentry.dart +++ b/lib/inventree/sentry.dart @@ -44,7 +44,7 @@ Future> getDeviceInfo() async { "hardware": androidDeviceInfo.hardware, "manufacturer": androidDeviceInfo.manufacturer, "product": androidDeviceInfo.product, - "version": androidDeviceInfo.version.release, + "systemVersion": androidDeviceInfo.version.release, "supported32BitAbis": androidDeviceInfo.supported32BitAbis, "supported64BitAbis": androidDeviceInfo.supported64BitAbis, "supportedAbis": androidDeviceInfo.supportedAbis, @@ -57,7 +57,8 @@ Future> getDeviceInfo() async { Map getServerInfo() => { - "version": InvenTreeAPI().version, + "version": InvenTreeAPI().serverVersion, + "apiVersion": InvenTreeAPI().apiVersion, }; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 673d284f..4d858d0d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -591,6 +591,15 @@ "locationUpdated": "Stock location updated", "@locationUpdated": {}, + "login": "Login", + "@login": {}, + + "loginEnter": "Enter login details", + "@loginEnter": {}, + + "loginEnterDetails": "Username and password are not stored locally", + "@loginEnterDetails": {}, + "link": "Link", "@link": {}, @@ -795,6 +804,9 @@ "profileDelete": "Delete Server Profile", "@profileDelete": {}, + "profileLogout": "Logout Profile", + "@profileLogout": {}, + "profileName": "Profile Name", "@profileName": {}, diff --git a/lib/settings/about.dart b/lib/settings/about.dart index 84578e03..3f02f6fc 100644 --- a/lib/settings/about.dart +++ b/lib/settings/about.dart @@ -96,10 +96,18 @@ class InvenTreeAboutWidget extends StatelessWidget { ) ); + tiles.add( + ListTile( + title: Text(L10().username), + subtitle: Text(InvenTreeAPI().username), + leading: InvenTreeAPI().username.isNotEmpty ? FaIcon(FontAwesomeIcons.user) : FaIcon(FontAwesomeIcons.userSlash, color: COLOR_DANGER), + ) + ); + tiles.add( ListTile( title: Text(L10().version), - subtitle: Text(InvenTreeAPI().version.isNotEmpty ? InvenTreeAPI().version : L10().notConnected), + subtitle: Text(InvenTreeAPI().serverVersion.isNotEmpty ? InvenTreeAPI().serverVersion : L10().notConnected), leading: FaIcon(FontAwesomeIcons.circleInfo), ) ); @@ -107,13 +115,13 @@ class InvenTreeAboutWidget extends StatelessWidget { tiles.add( ListTile( title: Text(L10().serverInstance), - subtitle: Text(InvenTreeAPI().instance.isNotEmpty ? InvenTreeAPI().instance : L10().notConnected), + subtitle: Text(InvenTreeAPI().serverInstance.isNotEmpty ? InvenTreeAPI().serverInstance : L10().notConnected), leading: FaIcon(FontAwesomeIcons.server), ) ); // Display extra tile if the server supports plugins - if (InvenTreeAPI().pluginsEnabled()) { + if (InvenTreeAPI().pluginsEnabled) { tiles.add( ListTile( title: Text(L10().pluginSupport), diff --git a/lib/settings/login.dart b/lib/settings/login.dart index 185a7797..57b1c161 100644 --- a/lib/settings/login.dart +++ b/lib/settings/login.dart @@ -1,295 +1,117 @@ + import "package:flutter/material.dart"; import "package:font_awesome_flutter/font_awesome_flutter.dart"; -import "package:one_context/one_context.dart"; - import "package:inventree/app_colors.dart"; -import "package:inventree/widget/dialogs.dart"; -import "package:inventree/widget/spinner.dart"; +import "package:inventree/user_profile.dart"; import "package:inventree/l10.dart"; import "package:inventree/api.dart"; -import "package:inventree/user_profile.dart"; +import "package:inventree/widget/dialogs.dart"; +import "package:inventree/widget/progress.dart"; -class InvenTreeLoginSettingsWidget extends StatefulWidget { + +class InvenTreeLoginWidget extends StatefulWidget { + + const InvenTreeLoginWidget(this.profile) : super(); + + final UserProfile profile; @override - _InvenTreeLoginSettingsState createState() => _InvenTreeLoginSettingsState(); + _InvenTreeLoginState createState() => _InvenTreeLoginState(); + } -class _InvenTreeLoginSettingsState extends State { - - _InvenTreeLoginSettingsState() { - _reload(); - } - - final GlobalKey<_InvenTreeLoginSettingsState> _loginKey = GlobalKey<_InvenTreeLoginSettingsState>(); - - List profiles = []; - - Future _reload() async { - - profiles = await UserProfileDBManager().getAllProfiles(); - - if (!mounted) { - return; - } - - setState(() { - }); - } - - void _editProfile(BuildContext context, {UserProfile? userProfile, bool createNew = false}) { - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ProfileEditWidget(userProfile) - ) - ).then((context) { - _reload(); - }); - } - - Future _selectProfile(BuildContext context, UserProfile profile) async { - - // Disconnect InvenTree - InvenTreeAPI().disconnectFromServer(); - - var key = profile.key; - - if (key == null) { - return; - } - - await UserProfileDBManager().selectProfile(key); - - if (!mounted) { - return; - } - - _reload(); - - // Attempt server login (this will load the newly selected profile - InvenTreeAPI().connectToServer().then((result) { - _reload(); - }); - - _reload(); - } - - Future _deleteProfile(UserProfile profile) async { - - await UserProfileDBManager().deleteProfile(profile); - - if (!mounted) { - return; - } - - _reload(); - - if (InvenTreeAPI().isConnected() && profile.key == (InvenTreeAPI().profile?.key ?? "")) { - InvenTreeAPI().disconnectFromServer(); - } - } - - Widget? _getProfileIcon(UserProfile profile) { - - // Not selected? No icon for you! - if (!profile.selected) return null; - - // Selected, but (for some reason) not the same as the API... - if ((InvenTreeAPI().profile?.key ?? "") != profile.key) { - return FaIcon( - FontAwesomeIcons.circleQuestion, - color: COLOR_WARNING - ); - } - - // Reflect the connection status of the server - if (InvenTreeAPI().isConnected()) { - return FaIcon( - FontAwesomeIcons.circleCheck, - color: COLOR_SUCCESS - ); - } else if (InvenTreeAPI().isConnecting()) { - return Spinner( - icon: FontAwesomeIcons.spinner, - color: COLOR_PROGRESS, - ); - } else { - return FaIcon( - FontAwesomeIcons.circleXmark, - color: COLOR_DANGER, - ); - } - } - - @override - Widget build(BuildContext context) { - - List children = []; - - if (profiles.isNotEmpty) { - for (int idx = 0; idx < profiles.length; idx++) { - UserProfile profile = profiles[idx]; - - children.add(ListTile( - title: Text( - profile.name, - ), - tileColor: profile.selected ? Theme.of(context).secondaryHeaderColor : null, - subtitle: Text("${profile.server}"), - trailing: _getProfileIcon(profile), - onTap: () { - _selectProfile(context, profile); - }, - onLongPress: () { - OneContext().showDialog( - builder: (BuildContext context) { - return SimpleDialog( - title: Text(profile.name), - children: [ - Divider(), - SimpleDialogOption( - onPressed: () { - Navigator.of(context).pop(); - _selectProfile(context, profile); - }, - child: ListTile( - title: Text(L10().profileConnect), - leading: FaIcon(FontAwesomeIcons.server), - ) - ), - SimpleDialogOption( - onPressed: () { - Navigator.of(context).pop(); - _editProfile(context, userProfile: profile); - }, - child: ListTile( - title: Text(L10().profileEdit), - leading: FaIcon(FontAwesomeIcons.penToSquare) - ) - ), - SimpleDialogOption( - onPressed: () { - Navigator.of(context).pop(); - // Navigator.of(context, rootNavigator: true).pop(); - confirmationDialog( - L10().delete, - L10().profileDelete + "?", - color: Colors.red, - icon: FontAwesomeIcons.trashCan, - onAccept: () { - _deleteProfile(profile); - } - ); - }, - child: ListTile( - title: Text(L10().profileDelete, style: TextStyle(color: Colors.red)), - leading: FaIcon(FontAwesomeIcons.trashCan, color: Colors.red), - ) - ) - ], - ); - } - ); - }, - )); - } - } else { - // No profile available! - children.add( - ListTile( - title: Text(L10().profileNone), - ) - ); - } - - return Scaffold( - key: _loginKey, - appBar: AppBar( - title: Text(L10().profileSelect), - actions: [ - IconButton( - icon: FaIcon(FontAwesomeIcons.circlePlus), - onPressed: () { - _editProfile(context, createNew: true); - }, - ) - ], - ), - body: Container( - child: ListView( - children: ListTile.divideTiles( - context: context, - tiles: children - ).toList(), - ) - ), - ); - } -} - - -class ProfileEditWidget extends StatefulWidget { - - const ProfileEditWidget(this.profile) : super(); - - final UserProfile? profile; - - @override - _ProfileEditState createState() => _ProfileEditState(); -} - -class _ProfileEditState extends State { - - _ProfileEditState() : super(); +class _InvenTreeLoginState extends State { final formKey = GlobalKey(); - String name = ""; - String server = ""; String username = ""; String password = ""; bool _obscured = true; + String error = ""; + + // Attempt login + Future _doLogin(BuildContext context) async { + + // Save form + formKey.currentState?.save(); + + bool valid = formKey.currentState?.validate() ?? false; + + if (valid) { + + // Dismiss the keyboard + FocusScopeNode currentFocus = FocusScope.of(context); + + if (!currentFocus.hasPrimaryFocus) { + currentFocus.unfocus(); + } + + showLoadingOverlay(context); + + // Attempt login + final response = await InvenTreeAPI().fetchToken(widget.profile, username, password); + + hideLoadingOverlay(); + + if (response.successful()) { + // Return to the server selector screen + Navigator.of(context).pop(); + } else { + var data = response.asMap(); + + String err; + + if (data.containsKey("detail")) { + err = (data["detail"] ?? "") as String; + } else { + err = statusCodeToString(response.statusCode); + } + setState(() { + error = err; + }); + } + } + + } + @override Widget build(BuildContext context) { + + List before = [ + ListTile( + title: Text(L10().loginEnter), + subtitle: Text(L10().loginEnterDetails), + leading: FaIcon(FontAwesomeIcons.userCheck), + ), + ListTile( + title: Text(L10().server), + subtitle: Text(widget.profile.server), + leading: FaIcon(FontAwesomeIcons.server), + ), + Divider(), + ]; + + List after = []; + + if (error.isNotEmpty) { + after.add(Divider()); + after.add(ListTile( + leading: FaIcon(FontAwesomeIcons.circleExclamation, color: COLOR_DANGER), + title: Text(L10().error, style: TextStyle(color: COLOR_DANGER)), + subtitle: Text(error, style: TextStyle(color: COLOR_DANGER)), + )); + } return Scaffold( appBar: AppBar( - title: Text(widget.profile == null ? L10().profileAdd : L10().profileEdit), + title: Text(L10().login), actions: [ IconButton( - icon: FaIcon(FontAwesomeIcons.floppyDisk), + icon: FaIcon(FontAwesomeIcons.arrowRightToBracket, color: COLOR_SUCCESS), onPressed: () async { - if (formKey.currentState!.validate()) { - formKey.currentState!.save(); - - UserProfile? prf = widget.profile; - - if (prf == null) { - UserProfile profile = UserProfile( - name: name, - server: server, - username: username, - password: password, - ); - - await UserProfileDBManager().addProfile(profile); - } else { - - prf.name = name; - prf.server = server; - prf.username = username; - prf.password = password; - - await UserProfileDBManager().updateProfile(prf); - } - - // Close the window - Navigator.of(context).pop(); - } + _doLogin(context); }, ) ] @@ -302,79 +124,14 @@ class _ProfileEditState extends State { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ + ...before, TextFormField( decoration: InputDecoration( - labelText: L10().profileName, - labelStyle: TextStyle(fontWeight: FontWeight.bold), + labelText: L10().username, + labelStyle: TextStyle(fontWeight: FontWeight.bold), + hintText: L10().enterUsername ), - initialValue: widget.profile?.name ?? "", - maxLines: 1, - keyboardType: TextInputType.text, - onSaved: (value) { - name = value?.trim() ?? ""; - }, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return L10().valueCannotBeEmpty; - } - - return null; - } - ), - TextFormField( - decoration: InputDecoration( - labelText: L10().server, - labelStyle: TextStyle(fontWeight: FontWeight.bold), - hintText: "http[s]://:", - ), - initialValue: widget.profile?.server ?? "", - keyboardType: TextInputType.url, - onSaved: (value) { - server = value?.trim() ?? ""; - }, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return L10().serverEmpty; - } - - value = value.trim(); - - // Spaces are bad - if (value.contains(" ")) { - return L10().invalidHost; - } - - if (!value.startsWith("http:") && !value.startsWith("https:")) { - // return L10().serverStart; - } - - Uri? _uri = Uri.tryParse(value); - - if (_uri == null || _uri.host.isEmpty) { - return L10().invalidHost; - } else { - Uri uri = Uri.parse(value); - - if (uri.hasScheme) { - if (!["http", "https"].contains(uri.scheme.toLowerCase())) { - return L10().serverStart; - } - } else { - return L10().invalidHost; - } - } - - // Everything is OK - return null; - }, - ), - TextFormField( - decoration: InputDecoration( - labelText: L10().username, - labelStyle: TextStyle(fontWeight: FontWeight.bold), - hintText: L10().enterUsername - ), - initialValue: widget.profile?.username ?? "", + initialValue: "", keyboardType: TextInputType.text, onSaved: (value) { username = value?.trim() ?? ""; @@ -388,39 +145,41 @@ class _ProfileEditState extends State { }, ), TextFormField( - decoration: InputDecoration( - labelText: L10().password, - labelStyle: TextStyle(fontWeight: FontWeight.bold), - hintText: L10().enterPassword, - suffixIcon: IconButton( - icon: _obscured ? FaIcon(FontAwesomeIcons.eye) : FaIcon(FontAwesomeIcons.solidEyeSlash), - onPressed: () { - setState(() { - _obscured = !_obscured; - }); - }, + decoration: InputDecoration( + labelText: L10().password, + labelStyle: TextStyle(fontWeight: FontWeight.bold), + hintText: L10().enterPassword, + suffixIcon: IconButton( + icon: _obscured ? FaIcon(FontAwesomeIcons.eye) : FaIcon(FontAwesomeIcons.solidEyeSlash), + onPressed: () { + setState(() { + _obscured = !_obscured; + }); + }, + ), ), - ), - initialValue: widget.profile?.password ?? "", - keyboardType: TextInputType.visiblePassword, - obscureText: _obscured, - onSaved: (value) { - password = value ?? ""; - }, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return L10().passwordEmpty; - } + initialValue: "", + keyboardType: TextInputType.visiblePassword, + obscureText: _obscured, + onSaved: (value) { + password = value?.trim() ?? ""; + }, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return L10().passwordEmpty; + } - return null; - } - ) - ] + return null; + } + ), + ...after, + ], ), padding: EdgeInsets.all(16), - ), + ) ) ); + } } \ No newline at end of file diff --git a/lib/settings/select_server.dart b/lib/settings/select_server.dart new file mode 100644 index 00000000..cd247b67 --- /dev/null +++ b/lib/settings/select_server.dart @@ -0,0 +1,430 @@ +import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/settings/login.dart"; +import "package:one_context/one_context.dart"; + +import "package:inventree/app_colors.dart"; +import "package:inventree/widget/dialogs.dart"; +import "package:inventree/widget/spinner.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/api.dart"; +import "package:inventree/user_profile.dart"; + +class InvenTreeSelectServerWidget extends StatefulWidget { + + @override + _InvenTreeSelectServerState createState() => _InvenTreeSelectServerState(); +} + + +class _InvenTreeSelectServerState extends State { + + _InvenTreeSelectServerState() { + _reload(); + } + + final GlobalKey<_InvenTreeSelectServerState> _loginKey = GlobalKey<_InvenTreeSelectServerState>(); + + List profiles = []; + + Future _reload() async { + + profiles = await UserProfileDBManager().getAllProfiles(); + + if (!mounted) { + return; + } + + setState(() { + }); + } + + /* + * Logout the selected profile (delete the stored token) + */ + Future _logoutProfile(BuildContext context, {UserProfile? userProfile}) async { + + if (userProfile != null) { + userProfile.token = ""; + await UserProfileDBManager().updateProfile(userProfile); + + _reload(); + } + + InvenTreeAPI().disconnectFromServer(); + _reload(); + + } + + /* + * Edit the selected profile + */ + void _editProfile(BuildContext context, {UserProfile? userProfile, bool createNew = false}) { + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ProfileEditWidget(userProfile) + ) + ).then((context) { + _reload(); + }); + } + + Future _selectProfile(BuildContext context, UserProfile profile) async { + + // Disconnect InvenTree + InvenTreeAPI().disconnectFromServer(); + + var key = profile.key; + + if (key == null) { + return; + } + + await UserProfileDBManager().selectProfile(key); + + UserProfile? prf = await UserProfileDBManager().getProfileByKey(key); + + if (prf == null) { + return; + } + + // First check if the profile has an associate token + if (!prf.hasToken) { + // Redirect user to login screen + Navigator.push(context, + MaterialPageRoute(builder: (context) => InvenTreeLoginWidget(profile)) + ).then((value) async { + _reload(); + // Reload profile + prf = await UserProfileDBManager().getProfileByKey(key); + if (prf?.hasToken ?? false) { + InvenTreeAPI().connectToServer(prf!).then((result) { + _reload(); + }); + } + }); + + // Exit now, login handled by next widget + return; + } + + if (!mounted) { + return; + } + + _reload(); + + // Attempt server login (this will load the newly selected profile + InvenTreeAPI().connectToServer(prf).then((result) { + _reload(); + }); + + _reload(); + } + + Future _deleteProfile(UserProfile profile) async { + + await UserProfileDBManager().deleteProfile(profile); + + if (!mounted) { + return; + } + + _reload(); + + if (InvenTreeAPI().isConnected() && profile.key == (InvenTreeAPI().profile?.key ?? "")) { + InvenTreeAPI().disconnectFromServer(); + } + } + + Widget? _getProfileIcon(UserProfile profile) { + + // Not selected? No icon for you! + if (!profile.selected) return null; + + // Selected, but (for some reason) not the same as the API... + if ((InvenTreeAPI().profile?.key ?? "") != profile.key) { + return null; + } + + // Reflect the connection status of the server + if (InvenTreeAPI().isConnected()) { + return FaIcon( + FontAwesomeIcons.circleCheck, + color: COLOR_SUCCESS + ); + } else if (InvenTreeAPI().isConnecting()) { + return Spinner( + icon: FontAwesomeIcons.spinner, + color: COLOR_PROGRESS, + ); + } else { + return FaIcon( + FontAwesomeIcons.circleXmark, + color: COLOR_DANGER, + ); + } + } + + @override + Widget build(BuildContext context) { + + List children = []; + + if (profiles.isNotEmpty) { + for (int idx = 0; idx < profiles.length; idx++) { + UserProfile profile = profiles[idx]; + + children.add(ListTile( + title: Text( + profile.name, + ), + tileColor: profile.selected ? Theme.of(context).secondaryHeaderColor : null, + subtitle: Text("${profile.server}"), + leading: profile.hasToken ? FaIcon(FontAwesomeIcons.userCheck, color: COLOR_SUCCESS) : FaIcon(FontAwesomeIcons.userSlash, color: COLOR_WARNING), + trailing: _getProfileIcon(profile), + onTap: () { + _selectProfile(context, profile); + }, + onLongPress: () { + OneContext().showDialog( + builder: (BuildContext context) { + return SimpleDialog( + title: Text(profile.name), + children: [ + Divider(), + SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(); + _selectProfile(context, profile); + }, + child: ListTile( + title: Text(L10().profileConnect), + leading: FaIcon(FontAwesomeIcons.server), + ) + ), + SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(); + _editProfile(context, userProfile: profile); + }, + child: ListTile( + title: Text(L10().profileEdit), + leading: FaIcon(FontAwesomeIcons.penToSquare) + ) + ), + SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(); + _logoutProfile(context, userProfile: profile); + }, + child: ListTile( + title: Text(L10().profileLogout), + leading: FaIcon(FontAwesomeIcons.userSlash), + ) + ), + Divider(), + SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(); + // Navigator.of(context, rootNavigator: true).pop(); + confirmationDialog( + L10().delete, + L10().profileDelete + "?", + color: Colors.red, + icon: FontAwesomeIcons.trashCan, + onAccept: () { + _deleteProfile(profile); + } + ); + }, + child: ListTile( + title: Text(L10().profileDelete, style: TextStyle(color: Colors.red)), + leading: FaIcon(FontAwesomeIcons.trashCan, color: Colors.red), + ) + ) + ], + ); + } + ); + }, + )); + } + } else { + // No profile available! + children.add( + ListTile( + title: Text(L10().profileNone), + ) + ); + } + + return Scaffold( + key: _loginKey, + appBar: AppBar( + title: Text(L10().profileSelect), + actions: [ + IconButton( + icon: FaIcon(FontAwesomeIcons.circlePlus), + onPressed: () { + _editProfile(context, createNew: true); + }, + ) + ], + ), + body: Container( + child: ListView( + children: ListTile.divideTiles( + context: context, + tiles: children + ).toList(), + ) + ), + ); + } +} + + +/* + * Widget for editing server details + */ +class ProfileEditWidget extends StatefulWidget { + + const ProfileEditWidget(this.profile) : super(); + + final UserProfile? profile; + + @override + _ProfileEditState createState() => _ProfileEditState(); +} + +class _ProfileEditState extends State { + + _ProfileEditState() : super(); + + final formKey = GlobalKey(); + + String name = ""; + String server = ""; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.profile == null ? L10().profileAdd : L10().profileEdit), + actions: [ + IconButton( + icon: FaIcon(FontAwesomeIcons.floppyDisk), + onPressed: () async { + if (formKey.currentState!.validate()) { + formKey.currentState!.save(); + + UserProfile? prf = widget.profile; + + if (prf == null) { + UserProfile profile = UserProfile( + name: name, + server: server, + ); + + await UserProfileDBManager().addProfile(profile); + } else { + + prf.name = name; + prf.server = server; + + await UserProfileDBManager().updateProfile(prf); + } + + // Close the window + Navigator.of(context).pop(); + } + }, + ) + ] + ), + body: Form( + key: formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + decoration: InputDecoration( + labelText: L10().profileName, + labelStyle: TextStyle(fontWeight: FontWeight.bold), + ), + initialValue: widget.profile?.name ?? "", + maxLines: 1, + keyboardType: TextInputType.text, + onSaved: (value) { + name = value?.trim() ?? ""; + }, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return L10().valueCannotBeEmpty; + } + + return null; + } + ), + TextFormField( + decoration: InputDecoration( + labelText: L10().server, + labelStyle: TextStyle(fontWeight: FontWeight.bold), + hintText: "http[s]://:", + ), + initialValue: widget.profile?.server ?? "", + keyboardType: TextInputType.url, + onSaved: (value) { + server = value?.trim() ?? ""; + }, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return L10().serverEmpty; + } + + value = value.trim(); + + // Spaces are bad + if (value.contains(" ")) { + return L10().invalidHost; + } + + if (!value.startsWith("http:") && !value.startsWith("https:")) { + // return L10().serverStart; + } + + Uri? _uri = Uri.tryParse(value); + + if (_uri == null || _uri.host.isEmpty) { + return L10().invalidHost; + } else { + Uri uri = Uri.parse(value); + + if (uri.hasScheme) { + if (!["http", "https"].contains(uri.scheme.toLowerCase())) { + return L10().serverStart; + } + } else { + return L10().invalidHost; + } + } + + // Everything is OK + return null; + }, + ), + ] + ), + padding: EdgeInsets.all(16), + ), + ) + ); + } + +} \ No newline at end of file diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart index 8c420718..dbf7e7e4 100644 --- a/lib/settings/settings.dart +++ b/lib/settings/settings.dart @@ -9,7 +9,7 @@ import "package:inventree/settings/about.dart"; import "package:inventree/settings/app_settings.dart"; import "package:inventree/settings/barcode_settings.dart"; import "package:inventree/settings/home_settings.dart"; -import "package:inventree/settings/login.dart"; +import "package:inventree/settings/select_server.dart"; import "package:inventree/settings/part_settings.dart"; @@ -51,7 +51,7 @@ class _InvenTreeSettingsState extends State { subtitle: Text(L10().configureServer), leading: FaIcon(FontAwesomeIcons.server, color: COLOR_ACTION), onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => InvenTreeLoginSettingsWidget())); + Navigator.push(context, MaterialPageRoute(builder: (context) => InvenTreeSelectServerWidget())); }, ), ListTile( diff --git a/lib/user_profile.dart b/lib/user_profile.dart index e1b54467..9d17b065 100644 --- a/lib/user_profile.dart +++ b/lib/user_profile.dart @@ -10,20 +10,21 @@ class UserProfile { this.key, this.name = "", this.server = "", - this.username = "", - this.password = "", + this.token = "", this.selected = false, }); factory UserProfile.fromJson(int key, Map json, bool isSelected) => UserProfile( key: key, - name: json["name"] as String, - server: json["server"] as String, - username: json["username"] as String, - password: json["password"] as String, + name: (json["name"] ?? "") as String, + server: (json["server"] ?? "") as String, + token: (json["token"] ?? "") as String, selected: isSelected, ); + // Return true if this profile has a token + bool get hasToken => token.isNotEmpty; + // ID of the profile int? key; @@ -33,11 +34,8 @@ class UserProfile { // Base address of the InvenTree server String server = ""; - // Username - String username = ""; - - // Password - String password = ""; + // API token + String token = ""; bool selected = false; @@ -47,13 +45,12 @@ class UserProfile { Map toJson() => { "name": name, "server": server, - "username": username, - "password": password, + "token": token, }; @override String toString() { - return "<${key}> ${name} : ${server} - ${username}:${password}"; + return "<${key}> ${name} : ${server}"; } } @@ -88,7 +85,7 @@ class UserProfileDBManager { */ Future addProfile(UserProfile profile) async { - if (profile.name.isEmpty || profile.username.isEmpty || profile.password.isEmpty) { + if (profile.name.isEmpty) { debug("addProfile() : Profile missing required values - not adding to database"); return false; } @@ -118,7 +115,7 @@ class UserProfileDBManager { Future updateProfile(UserProfile profile) async { // Prevent invalid profile data from being updated - if (profile.name.isEmpty || profile.username.isEmpty || profile.password.isEmpty) { + if (profile.name.isEmpty) { debug("updateProfile() : Profile missing required values - not updating"); return false; } @@ -204,8 +201,6 @@ class UserProfileDBManager { UserProfile demoProfile = UserProfile( name: "InvenTree Demo", server: "https://demo.inventree.org", - username: "allaccess", - password: "nolimits", ); await addProfile(demoProfile); @@ -217,6 +212,26 @@ class UserProfileDBManager { return profileList; } + + /* + * Retrieve a profile by key (or null if no match exists) + */ + Future getProfileByKey(int key) async { + final profiles = await getAllProfiles(); + + UserProfile? prf; + + for (UserProfile profile in profiles) { + if (profile.key == key) { + prf = profile; + break; + } + } + + return prf; + } + + /* * Retrieve a profile by name (or null if no match exists) */ diff --git a/lib/widget/dialogs.dart b/lib/widget/dialogs.dart index 1c86ef2f..88229c13 100644 --- a/lib/widget/dialogs.dart +++ b/lib/widget/dialogs.dart @@ -235,50 +235,9 @@ Future showServerError(String url, String title, String description) async */ Future showStatusCodeError(String url, int status, {String details=""}) async { - String msg = L10().responseInvalid; + String msg = statusCodeToString(status); String extra = url + "\n" + "${L10().statusCode}: ${status}"; - switch (status) { - case 400: - msg = L10().response400; - break; - case 401: - msg = L10().response401; - break; - case 403: - msg = L10().response403; - break; - case 404: - msg = L10().response404; - break; - case 405: - msg = L10().response405; - break; - case 429: - msg = L10().response429; - break; - case 500: - msg = L10().response500; - break; - case 501: - msg = L10().response501; - break; - case 502: - msg = L10().response502; - break; - case 503: - msg = L10().response503; - break; - case 504: - msg = L10().response504; - break; - case 505: - msg = L10().response505; - break; - default: - break; - } - if (details.isNotEmpty) { extra += "\n"; extra += details; @@ -292,6 +251,41 @@ Future showStatusCodeError(String url, int status, {String details=""}) as } +/* + * Provide a human-readable descriptor for a particular error code + */ +String statusCodeToString(int status) { + switch (status) { + case 400: + return L10().response400; + case 401: + return L10().response401; + case 403: + return L10().response403; + case 404: + return L10().response404; + case 405: + return L10().response405; + case 429: + return L10().response429; + case 500: + return L10().response500; + case 501: + return L10().response501; + case 502: + return L10().response502; + case 503: + return L10().response503; + case 504: + return L10().response504; + case 505: + return L10().response505; + default: + return L10().responseInvalid + " : ${status}"; + } +} + + /* * Displays a message indicating that the server timed out on a certain request */ diff --git a/lib/widget/home.dart b/lib/widget/home.dart index 5fa8a2e9..b62daf77 100644 --- a/lib/widget/home.dart +++ b/lib/widget/home.dart @@ -8,7 +8,7 @@ import "package:inventree/api.dart"; import "package:inventree/app_colors.dart"; import "package:inventree/preferences.dart"; import "package:inventree/l10.dart"; -import "package:inventree/settings/login.dart"; +import "package:inventree/settings/select_server.dart"; import "package:inventree/user_profile.dart"; import "package:inventree/widget/category_display.dart"; @@ -119,7 +119,7 @@ class _InvenTreeHomePageState extends State with BaseWidgetPr void _selectProfile() { Navigator.push( - context, MaterialPageRoute(builder: (context) => InvenTreeLoginSettingsWidget()) + context, MaterialPageRoute(builder: (context) => InvenTreeSelectServerWidget()) ).then((context) { // Once we return _loadProfile(); @@ -147,7 +147,7 @@ class _InvenTreeHomePageState extends State with BaseWidgetPr if (!InvenTreeAPI().isConnected() && !InvenTreeAPI().isConnecting()) { // Attempt server connection - InvenTreeAPI().connectToServer().then((result) { + InvenTreeAPI().connectToServer(_profile!).then((result) { if (mounted) { setState(() {}); } diff --git a/test/api_test.dart b/test/api_test.dart index 5990d161..a9c72910 100644 --- a/test/api_test.dart +++ b/test/api_test.dart @@ -17,37 +17,11 @@ void main() { setUp(() async { - if (! await UserProfileDBManager().profileNameExists("Test Profile")) { - // Create and select a profile to user - - print("TEST: Creating profile for user 'testuser'"); - - await UserProfileDBManager().addProfile(UserProfile( - name: "Test Profile", - server: "http://localhost:12345", - username: "testuser", - password: "testpassword", - selected: true, - )); - } - - var prf = await UserProfileDBManager().getSelectedProfile(); - - // Ensure that the server settings are correct by default, - // as they can get overwritten by subsequent tests - - if (prf != null) { - prf.name = "Test Profile"; - prf.server = "http://localhost:12345"; - prf.username = "testuser"; - prf.password = "testpassword"; - - await UserProfileDBManager().updateProfile(prf); - } + await setupServerProfile(select: true); // Ensure the profile is selected assert(! await UserProfileDBManager().selectProfileByName("Missing Profile")); - assert(await UserProfileDBManager().selectProfileByName("Test Profile")); + assert(await UserProfileDBManager().selectProfileByName(testServerName)); }); @@ -71,53 +45,57 @@ void main() { var api = InvenTreeAPI(); // Incorrect server address - var profile = await UserProfileDBManager().getSelectedProfile(); + var profile = await setupServerProfile(); - assert(profile != null); + profile.server = "http://localhost:5555"; - if (profile != null) { - profile.server = "http://localhost:5555"; - await UserProfileDBManager().updateProfile(profile); + bool result = await api.connectToServer(profile); + assert(!result); - bool result = await api.connectToServer(); - assert(!result); + debugContains("SocketException at"); - debugContains("SocketException at"); + // Test incorrect login details + profile.server = testServerAddress; - // Test incorrect login details - profile.server = "http://localhost:12345"; - profile.username = "invalidusername"; + final response = await api.fetchToken(profile, "baduser", "badpassword"); + assert(!response.successful()); - await UserProfileDBManager().updateProfile(profile); + debugContains("Token request failed"); - await api.connectToServer(); - assert(!result); + assert(!api.checkConnection()); - debugContains("Token request failed"); + debugContains("Token request failed: STATUS 401"); + debugContains("showSnackIcon: 'Not Connected'"); - assert(!api.checkConnection()); + }); - debugContains("Token request failed: STATUS 401"); - debugContains("showSnackIcon: 'Not Connected'"); + test("Bad Token", () async { + // Test that login fails with a bad token + var profile = await setupServerProfile(); - } else { - assert(false); - } + profile.token = "bad-token"; + bool result = await InvenTreeAPI().connectToServer(profile); + assert(!result); }); test("Login Success", () async { // Test that we can login to the server successfully var api = InvenTreeAPI(); - // Attempt to connect - final bool result = await api.connectToServer(); + final profile = await setupServerProfile(select: true, fetchToken: true); + assert(profile.hasToken); + + // Now, connect to the server + bool result = await api.connectToServer(profile); // Check expected values assert(result); assert(api.hasToken); - expect(api.baseUrl, equals("http://localhost:12345/")); + expect(api.baseUrl, equals(testServerAddress)); + + assert(api.hasToken); assert(api.isConnected()); assert(!api.isConnecting()); assert(api.checkConnection()); @@ -127,7 +105,8 @@ void main() { // Test server version information var api = InvenTreeAPI(); - assert(await api.connectToServer()); + final profile = await setupServerProfile(fetchToken: true); + assert(await api.connectToServer(profile)); // Check supported functions assert(api.apiVersion >= 50); @@ -135,12 +114,15 @@ void main() { assert(api.supportsNotifications); assert(api.supportsPoReceive); - // Ensure we can request (and receive) user roles - assert(await api.getUserRoles()); + assert(api.serverInstance.isNotEmpty); + assert(api.serverVersion.isNotEmpty); + + // Ensure we can have user role data + assert(api.roles.isNotEmpty); // Check available permissions assert(api.checkPermission("part", "change")); - assert(api.checkPermission("stocklocation", "delete")); + assert(api.checkPermission("stock_location", "delete")); assert(!api.checkPermission("part", "weirdpermission")); assert(api.checkPermission("blah", "bloo")); diff --git a/test/barcode_test.dart b/test/barcode_test.dart index e28715b3..181e74d2 100644 --- a/test/barcode_test.dart +++ b/test/barcode_test.dart @@ -10,7 +10,6 @@ import "package:flutter_test/flutter_test.dart"; import "package:inventree/api.dart"; import "package:inventree/barcode/barcode.dart"; import "package:inventree/helpers.dart"; -import "package:inventree/user_profile.dart"; import "package:inventree/inventree/part.dart"; import "package:inventree/inventree/stock.dart"; @@ -23,26 +22,7 @@ void main() { // Connect to the server setUpAll(() async { - final prf = await UserProfileDBManager().getProfileByName("Test Profile"); - - if (prf != null) { - await UserProfileDBManager().deleteProfile(prf); - } - - bool result = await UserProfileDBManager().addProfile( - UserProfile( - name: "Test Profile", - server: "http://localhost:12345", - username: "testuser", - password: "testpassword", - selected: true, - ), - ); - - assert(result); - - assert(await UserProfileDBManager().selectProfileByName("Test Profile")); - assert(await InvenTreeAPI().connectToServer()); + await connectToTestServer(); }); setUp(() async { @@ -91,8 +71,8 @@ void main() { test("Scan Into Location", () async { final item = await InvenTreeStockItem().get(1) as InvenTreeStockItem?; - assert(item != null); + assert(item!.pk == 1); var handler = StockItemScanIntoLocationHandler(item!); diff --git a/test/models_test.dart b/test/models_test.dart index c44ad5cd..5a82d474 100644 --- a/test/models_test.dart +++ b/test/models_test.dart @@ -5,7 +5,6 @@ import "package:test/test.dart"; import "package:inventree/api.dart"; -import "package:inventree/user_profile.dart"; import "package:inventree/inventree/model.dart"; import "package:inventree/inventree/part.dart"; @@ -16,16 +15,7 @@ void main() { setupTestEnv(); setUp(() async { - await UserProfileDBManager().addProfile(UserProfile( - name: "Test Profile", - server: "http://localhost:12345", - username: "testuser", - password: "testpassword", - selected: true, - )); - - assert(await UserProfileDBManager().selectProfileByName("Test Profile")); - assert(await InvenTreeAPI().connectToServer()); + await connectToTestServer(); }); group("Category Tests:", () { diff --git a/test/setup.dart b/test/setup.dart index 1338f132..b9785b7d 100644 --- a/test/setup.dart +++ b/test/setup.dart @@ -1,6 +1,8 @@ import "package:flutter/services.dart"; import "package:flutter_test/flutter_test.dart"; +import "package:inventree/api.dart"; +import "package:inventree/user_profile.dart"; // This is the same as the following issue except it keeps the http client // TestWidgetsFlutterBinding.ensureInitialized(); @@ -19,4 +21,78 @@ void setupTestEnv() { .setMockMethodCallHandler(channel, (MethodCall methodCall) async { return "."; }); +} + +// Accessors for default testing values +const String testServerAddress = "http://localhost:8000/"; +const String testServerName = "Test Server"; +const String testUsername = "testuser"; +const String testPassword = "testpassword"; + + +/* + * Request an API token for the given profile + */ +Future fetchProfileToken({ + UserProfile? profile, + String username = testUsername, + String password = testPassword +}) async { + + profile ??= await UserProfileDBManager().getProfileByName(testServerName); + + assert(profile != null); + + final response = await InvenTreeAPI().fetchToken(profile!, username, password); + return response.successful(); +} + + +/* + * Setup a valid profile, and return it + */ +Future setupServerProfile({bool select = true, bool fetchToken = false}) async { + // Setup a valid server profile + + UserProfile? profile = await UserProfileDBManager().getProfileByName(testServerName); + + if (profile == null) { + // Profile does not already exist - create it! + bool result = await UserProfileDBManager().addProfile( + UserProfile( + server: testServerAddress, + name: testServerName + ) + ); + + assert(result); + } + + profile = await UserProfileDBManager().getProfileByName(testServerName); + assert(profile != null); + + if (select) { + assert(await UserProfileDBManager().selectProfileByName(testServerName)); + } + + if (fetchToken && !profile!.hasToken) { + final bool result = await fetchProfileToken(profile: profile); + assert(result); + assert(profile.hasToken); + } + + return profile!; +} + + +/* + * Complete all steps necessary to login to the server + */ +Future connectToTestServer() async { + + // Setup profile, and fetch user token as necessary + final profile = await setupServerProfile(fetchToken: true); + + // Connect to the server + assert(await InvenTreeAPI().connectToServer(profile)); } \ No newline at end of file diff --git a/test/user_profile_test.dart b/test/user_profile_test.dart index 1da201fa..0921d761 100644 --- a/test/user_profile_test.dart +++ b/test/user_profile_test.dart @@ -27,10 +27,8 @@ void main() { // Now, create one! bool result = await UserProfileDBManager().addProfile(UserProfile( - name: "Test Profile", - username: "testuser", - password: "testpassword""", - server: "http://localhost:12345", + name: testServerName, + server: testServerAddress, selected: true, )); @@ -62,20 +60,15 @@ void main() { test("Add Invalid Profiles", () async { // Add a profile with missing data bool result = await UserProfileDBManager().addProfile( - UserProfile( - username: "what", - password: "why", - ) + UserProfile() ); expect(result, equals(false)); - // Add a profile with a name that already exists + // Add a profile with a new name result = await UserProfileDBManager().addProfile( UserProfile( - name: "Test Profile", - username: "xyz", - password: "hunter42", + name: "Another Test Profile", ) ); @@ -84,14 +77,14 @@ void main() { // Check that the number of protocols available is still the same var profiles = await UserProfileDBManager().getAllProfiles(); - expect(profiles.length, equals(1)); + expect(profiles.length, equals(2)); }); test("Profile Name Check", () async { bool result = await UserProfileDBManager().profileNameExists("doesnotexist"); expect(result, equals(false)); - result = await UserProfileDBManager().profileNameExists("Test Profile"); + result = await UserProfileDBManager().profileNameExists("Test Server"); expect(result, equals(true)); }); @@ -104,23 +97,16 @@ void main() { if (prf != null) { UserProfile p = prf; - expect(p.name, equals("Test Profile")); - expect(p.username, equals("testuser")); - expect(p.password, equals("testpassword")); - expect(p.server, equals("http://localhost:12345")); + expect(p.name, equals(testServerName)); + expect(p.server, equals(testServerAddress)); - expect(p.toString(), equals("<${p.key}> Test Profile : http://localhost:12345 - testuser:testpassword")); + expect(p.toString(), equals("<${p.key}> Test Server : http://localhost:8000/")); // Test that we can update the profile p.name = "different name"; bool result = await UserProfileDBManager().updateProfile(p); expect(result, equals(true)); - - // Trying to update with an invalid value will fail! - p.password = ""; - result = await UserProfileDBManager().updateProfile(p); - expect(result, equals(false)); } }); });