mirror of
				https://github.com/inventree/inventree-app.git
				synced 2025-10-31 05:15:42 +00:00 
			
		
		
		
	Token auth (#434)
* Embed device platform information into token request * Remove username and password from userProfile * Display icon to show if profile has associated user token * Remove username / password from login settings screen * Refactor login procedure around token auth * Refactoring * Add profile login screen - Username / password values are not stored - Just to fetch api token * Login with basic auth * Pass profile to API when connecting * Remove _BASE_URL accessor - Fixes URL caching bug * Add more context to login screen * Add helper functions for unit tests - Change default port to 8000 (makes testing easier with local inventree instance) * api.dart handles basic auth now * fix api_test.dart * Further test improvements * linting fixes * Provide feedback when login fails * More linting * Record user details on login, and display in "about" widget * Fix string lookup * Add extra debug * Fix auth values * Fix user profile test
This commit is contained in:
		
							
								
								
									
										5
									
								
								.github/workflows/ci.yaml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/workflows/ci.yaml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
| --- | ||||
|   | ||||
							
								
								
									
										371
									
								
								lib/api.dart
									
									
									
									
									
								
							
							
						
						
									
										371
									
								
								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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<InvenTreePlugin> _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,29 +371,64 @@ 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<bool> _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<bool> _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<bool> _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, | ||||
| @@ -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<String, dynamic> _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<bool> _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<APIResponse> 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,10 +584,12 @@ 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( | ||||
| @@ -511,56 +597,16 @@ class InvenTreeAPI { | ||||
|         L10().tokenMissing, | ||||
|         L10().tokenMissingFromResponse, | ||||
|       ); | ||||
|  | ||||
|       return false; | ||||
|     } | ||||
|  | ||||
|     // Return the received token | ||||
|     _token = (data["token"] ?? "") as String; | ||||
|     // Save the token to the user profile | ||||
|     userProfile.token = (data["token"] ?? "") as String; | ||||
|  | ||||
|     debug("Received token from server"); | ||||
|     debug("Received token from server: ${userProfile.token}"); | ||||
|  | ||||
|     bool result = false; | ||||
|  | ||||
|     // Request user role information (async) | ||||
|     result = await getUserRoles(); | ||||
|  | ||||
|     if (!result) { | ||||
|       showServerError( | ||||
|         apiUrl, | ||||
|         L10().serverError, | ||||
|         L10().errorUserRoles, | ||||
|       ); | ||||
|  | ||||
|       return false; | ||||
|     } | ||||
|  | ||||
|     // Request plugin information (async) | ||||
|     result = await getPluginInformation(); | ||||
|  | ||||
|     if (!result) { | ||||
|       showServerError( | ||||
|         apiUrl, | ||||
|         L10().serverError, | ||||
|         L10().errorPluginInfo | ||||
|       ); | ||||
|  | ||||
|       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<bool> connectToServer() async { | ||||
|  | ||||
|   /* Public facing connection function. | ||||
|    */ | ||||
|   Future<bool> 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<bool> getUserRoles() async { | ||||
|   Future<bool> _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<bool> getPluginInformation() async { | ||||
|   Future<bool> _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<HttpClientRequest?> apiRequest(String url, String method, {Map<String, String> urlParams = const {}}) async { | ||||
|   Future<HttpClientRequest?> apiRequest( | ||||
|       String url, | ||||
|       String method, | ||||
|       { | ||||
|         Map<String, String> urlParams = const {}, | ||||
|         Map<String, String> 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<APIResponse> get(String url, {Map<String, String> params = const {}, int? expectedStatusCode=200}) async { | ||||
|   Future<APIResponse> get(String url, {Map<String, String> params = const {}, Map<String, String> headers = const {}, int? expectedStatusCode=200}) async { | ||||
|  | ||||
|     HttpClientRequest? request = await apiRequest( | ||||
|       url, | ||||
|       "GET", | ||||
|       urlParams: params, | ||||
|       headers: headers, | ||||
|     ); | ||||
|  | ||||
|  | ||||
| @@ -1334,7 +1405,10 @@ class InvenTreeAPI { | ||||
|   Map<String, String> defaultHeaders() { | ||||
|     Map<String, String> headers = {}; | ||||
|  | ||||
|     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 { | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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<List<InvenTreePOLineItem>> getLineItems() async { | ||||
|  | ||||
|   | ||||
| @@ -44,7 +44,7 @@ Future<Map<String, dynamic>> 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<Map<String, dynamic>> getDeviceInfo() async { | ||||
|  | ||||
|  | ||||
| Map<String, dynamic> getServerInfo() => { | ||||
|   "version": InvenTreeAPI().version, | ||||
|   "version": InvenTreeAPI().serverVersion, | ||||
|   "apiVersion": InvenTreeAPI().apiVersion, | ||||
| }; | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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": {}, | ||||
|  | ||||
|   | ||||
| @@ -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), | ||||
|   | ||||
| @@ -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<InvenTreeLoginSettingsWidget> { | ||||
|  | ||||
|   _InvenTreeLoginSettingsState() { | ||||
|     _reload(); | ||||
|   } | ||||
|  | ||||
|   final GlobalKey<_InvenTreeLoginSettingsState> _loginKey = GlobalKey<_InvenTreeLoginSettingsState>(); | ||||
|  | ||||
|   List<UserProfile> profiles = []; | ||||
|  | ||||
|   Future <void> _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 <void> _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 <void> _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<Widget> 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: <Widget>[ | ||||
|                       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<ProfileEditWidget> { | ||||
|  | ||||
|   _ProfileEditState() : super(); | ||||
| class _InvenTreeLoginState extends State<InvenTreeLoginWidget> { | ||||
|  | ||||
|   final formKey = GlobalKey<FormState>(); | ||||
|  | ||||
|   String name = ""; | ||||
|   String server = ""; | ||||
|   String username = ""; | ||||
|   String password = ""; | ||||
|  | ||||
|   bool _obscured = true; | ||||
|  | ||||
|   String error = ""; | ||||
|  | ||||
|   // Attempt login | ||||
|   Future<void> _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<Widget> 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<Widget> 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<ProfileEditWidget> { | ||||
|             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]://<server>:<port>", | ||||
|                 ), | ||||
|                 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; | ||||
|                 }, | ||||
|               ), | ||||
|               ...before, | ||||
|               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() ?? ""; | ||||
| @@ -401,11 +158,11 @@ class _ProfileEditState extends State<ProfileEditWidget> { | ||||
|                       }, | ||||
|                     ), | ||||
|                   ), | ||||
|                 initialValue: widget.profile?.password ?? "", | ||||
|                   initialValue: "", | ||||
|                   keyboardType: TextInputType.visiblePassword, | ||||
|                   obscureText: _obscured, | ||||
|                   onSaved: (value) { | ||||
|                   password = value ?? ""; | ||||
|                     password = value?.trim() ?? ""; | ||||
|                   }, | ||||
|                   validator: (value) { | ||||
|                     if (value == null || value.trim().isEmpty) { | ||||
| @@ -414,13 +171,15 @@ class _ProfileEditState extends State<ProfileEditWidget> { | ||||
|  | ||||
|                     return null; | ||||
|                   } | ||||
|               ) | ||||
|             ] | ||||
|               ), | ||||
|               ...after, | ||||
|             ], | ||||
|           ), | ||||
|           padding: EdgeInsets.all(16), | ||||
|         ), | ||||
|         ) | ||||
|       ) | ||||
|     ); | ||||
|  | ||||
|   } | ||||
|  | ||||
| } | ||||
							
								
								
									
										430
									
								
								lib/settings/select_server.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										430
									
								
								lib/settings/select_server.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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<InvenTreeSelectServerWidget> { | ||||
|  | ||||
|   _InvenTreeSelectServerState() { | ||||
|     _reload(); | ||||
|   } | ||||
|  | ||||
|   final GlobalKey<_InvenTreeSelectServerState> _loginKey = GlobalKey<_InvenTreeSelectServerState>(); | ||||
|  | ||||
|   List<UserProfile> profiles = []; | ||||
|  | ||||
|   Future <void> _reload() async { | ||||
|  | ||||
|     profiles = await UserProfileDBManager().getAllProfiles(); | ||||
|  | ||||
|     if (!mounted) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setState(() { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   /* | ||||
|    * Logout the selected profile (delete the stored token) | ||||
|    */ | ||||
|   Future<void> _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 <void> _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 <void> _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<Widget> 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: <Widget>[ | ||||
|                       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<ProfileEditWidget> { | ||||
|  | ||||
|   _ProfileEditState() : super(); | ||||
|  | ||||
|   final formKey = GlobalKey<FormState>(); | ||||
|  | ||||
|   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]://<server>:<port>", | ||||
|                 ), | ||||
|                 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), | ||||
|         ), | ||||
|       ) | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -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<InvenTreeSettingsWidget> { | ||||
|                   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( | ||||
|   | ||||
| @@ -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<String, dynamic> 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<String, dynamic> 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<bool> 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<bool> 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<UserProfile?> 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) | ||||
|    */ | ||||
|   | ||||
| @@ -235,50 +235,9 @@ Future<void> showServerError(String url, String title, String description) async | ||||
|  */ | ||||
| Future<void> 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<void> 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 | ||||
|  */ | ||||
|   | ||||
| @@ -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<InvenTreeHomePage> 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<InvenTreeHomePage> with BaseWidgetPr | ||||
|       if (!InvenTreeAPI().isConnected() && !InvenTreeAPI().isConnecting()) { | ||||
|  | ||||
|         // Attempt server connection | ||||
|         InvenTreeAPI().connectToServer().then((result) { | ||||
|         InvenTreeAPI().connectToServer(_profile!).then((result) { | ||||
|           if (mounted) { | ||||
|             setState(() {}); | ||||
|           } | ||||
|   | ||||
| @@ -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,27 +45,20 @@ void main() { | ||||
|       var api = InvenTreeAPI(); | ||||
|  | ||||
|       // Incorrect server address | ||||
|       var profile = await UserProfileDBManager().getSelectedProfile(); | ||||
|       var profile = await setupServerProfile(); | ||||
|  | ||||
|       assert(profile != null); | ||||
|  | ||||
|       if (profile != null) { | ||||
|       profile.server = "http://localhost:5555"; | ||||
|         await UserProfileDBManager().updateProfile(profile); | ||||
|  | ||||
|         bool result = await api.connectToServer(); | ||||
|       bool result = await api.connectToServer(profile); | ||||
|       assert(!result); | ||||
|  | ||||
|       debugContains("SocketException at"); | ||||
|  | ||||
|       // Test incorrect login details | ||||
|         profile.server = "http://localhost:12345"; | ||||
|         profile.username = "invalidusername"; | ||||
|       profile.server = testServerAddress; | ||||
|  | ||||
|         await UserProfileDBManager().updateProfile(profile); | ||||
|  | ||||
|         await api.connectToServer(); | ||||
|         assert(!result); | ||||
|       final response = await api.fetchToken(profile, "baduser", "badpassword"); | ||||
|       assert(!response.successful()); | ||||
|  | ||||
|       debugContains("Token request failed"); | ||||
|  | ||||
| @@ -100,24 +67,35 @@ void main() { | ||||
|       debugContains("Token request failed: STATUS 401"); | ||||
|       debugContains("showSnackIcon: 'Not Connected'"); | ||||
|  | ||||
|       } else { | ||||
|         assert(false); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     test("Bad Token", () async { | ||||
|       // Test that login fails with a bad token | ||||
|       var profile = await setupServerProfile(); | ||||
|  | ||||
|       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")); | ||||
|  | ||||
|   | ||||
| @@ -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!); | ||||
|   | ||||
| @@ -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:", () { | ||||
|   | ||||
| @@ -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(); | ||||
| @@ -20,3 +22,77 @@ void setupTestEnv() { | ||||
|     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<bool> 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<UserProfile> 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<void> 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)); | ||||
| } | ||||
| @@ -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)); | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user