2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-04-27 21:16:48 +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:
Oliver 2023-10-23 01:29:16 +11:00 committed by GitHub
parent 382c8461f9
commit 76b6191a67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1023 additions and 705 deletions

View File

@ -18,9 +18,6 @@ env:
INVENTREE_ADMIN_USER: testuser INVENTREE_ADMIN_USER: testuser
INVENTREE_ADMIN_PASSWORD: testpassword INVENTREE_ADMIN_PASSWORD: testpassword
INVENTREE_ADMIN_EMAIL: test@test.com INVENTREE_ADMIN_EMAIL: test@test.com
INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345
INVENTREE_PYTHON_TEST_USERNAME: testuser
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
jobs: jobs:
test: test:
@ -64,7 +61,7 @@ jobs:
invoke install invoke install
invoke migrate invoke migrate
invoke import-fixtures invoke import-fixtures
invoke server -a 127.0.0.1:12345 & invoke server -a 127.0.0.1:8000 &
invoke wait invoke wait
sleep 30 sleep 30
- name: Unit Tests - name: Unit Tests

View File

@ -2,6 +2,9 @@
--- ---
- Add ability to scan in received items using supplier barcodes - 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 ### 0.12.8 - September 2023
--- ---

View File

@ -192,16 +192,13 @@ class InvenTreeAPI {
bool _strictHttps = false; bool _strictHttps = false;
// Endpoint for requesting an API token // Endpoint for requesting an API token
static const _URL_GET_TOKEN = "user/token/"; static const _URL_TOKEN = "user/token/";
static const _URL_ROLES = "user/roles/";
static const _URL_GET_ROLES = "user/roles/"; static const _URL_ME = "user/me/";
// Base URL for InvenTree API e.g. http://192.168.120.10:8000
String _BASE_URL = "";
// Accessors for various url endpoints // Accessors for various url endpoints
String get baseUrl { String get baseUrl {
String url = _BASE_URL; String url = profile?.server ?? "";
if (!url.endsWith("/")) { if (!url.endsWith("/")) {
url += "/"; url += "/";
@ -242,21 +239,22 @@ class InvenTreeAPI {
// Available user roles (permissions) are loaded when connecting to the server // Available user roles (permissions) are loaded when connecting to the server
Map<String, dynamic> roles = {}; Map<String, dynamic> roles = {};
// Authentication token (initially empty, must be requested) // Profile authentication token
String _token = ""; String get token => profile?.token ?? "";
bool get hasToken => token.isNotEmpty;
String? get serverAddress { String? get serverAddress {
return profile?.server; return profile?.server;
} }
bool get hasToken => _token.isNotEmpty;
/* /*
* Check server connection and display messages if not connected. * Check server connection and display messages if not connected.
* Useful as a precursor check before performing operations. * Useful as a precursor check before performing operations.
*/ */
bool checkConnection() { bool checkConnection() {
// Firstly, is the server connected?
// Is the server connected?
if (!isConnected()) { if (!isConnected()) {
showSnackIcon( showSnackIcon(
@ -272,16 +270,20 @@ class InvenTreeAPI {
return true; return true;
} }
// Server instance information // Map of user information
String instance = ""; Map<String, dynamic> userInfo = {};
// Server version information String get username => (userInfo["username"] ?? "") as String;
String _version = "";
// API version of the connected server // Map of server information
int _apiVersion = 1; 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 // API endpoint for receiving purchase order line items was introduced in v12
bool get supportsPoReceive => apiVersion >= 12; bool get supportsPoReceive => apiVersion >= 12;
@ -330,13 +332,6 @@ class InvenTreeAPI {
bool get supportsBarcodePOReceiveEndpoint => isConnected() && apiVersion >= 139; 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) // Cached list of plugins (refreshed when we connect to the server)
List<InvenTreePlugin> _plugins = []; List<InvenTreePlugin> _plugins = [];
@ -363,9 +358,6 @@ class InvenTreeAPI {
// Test if the provided plugin mixin is supported by any active plugins // Test if the provided plugin mixin is supported by any active plugins
bool supportsMixin(String mixin) => getPlugins(mixin: mixin).isNotEmpty; 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 // Connection status flag - set once connection has been validated
bool _connected = false; bool _connected = false;
@ -379,33 +371,68 @@ class InvenTreeAPI {
return !isConnected() && _connecting; 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 address = profile?.server ?? "";
String username = profile?.username ?? "";
String password = profile?.password ?? "";
address = address.trim(); if (address.isEmpty) {
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) {
showSnackIcon( showSnackIcon(
L10().incompleteDetails, L10().incompleteDetails,
icon: FontAwesomeIcons.circleExclamation, icon: FontAwesomeIcons.circleExclamation,
success: false success: false
); );
return false; return false;
} }
@ -414,27 +441,24 @@ class InvenTreeAPI {
address = address + "/"; 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 debug("Connecting to ${apiUrl}");
_plugins.clear();
debug("Connecting to ${apiUrl} -> username=${username}"); APIResponse response = await get("", expectedStatusCode: 200);
APIResponse response;
response = await get("", expectedStatusCode: 200);
if (!response.successful()) { if (!response.successful()) {
debug("Server returned invalid response: ${response.statusCode}");
showStatusCodeError(apiUrl, response.statusCode, details: response.data.toString()); showStatusCodeError(apiUrl, response.statusCode, details: response.data.toString());
return false; return false;
} }
var data = response.asMap(); Map<String, dynamic> _data = response.asMap();
// We expect certain response from the server serverInfo = {..._data};
if (!data.containsKey("server") || !data.containsKey("version") || !data.containsKey("instance")) {
if (serverVersion.isEmpty) {
showServerError( showServerError(
apiUrl, apiUrl,
L10().missingData, L10().missingData,
@ -444,17 +468,9 @@ class InvenTreeAPI {
return false; return false;
} }
// Record server information if (apiVersion < _minApiVersion) {
_version = (data["version"] ?? "") as String;
instance = (data["instance"] ?? "") as String;
// Default API version is 1 if not provided String message = L10().serverApiVersion + ": ${apiVersion}";
_apiVersion = (data["apiVersion"] ?? 1) as int;
_pluginsEnabled = (data["plugins_enabled"] ?? false) as bool;
if (_apiVersion < _minApiVersion) {
String message = L10().serverApiVersion + ": ${_apiVersion}";
message += "\n"; message += "\n";
message += L10().serverApiRequired + ": ${_minApiVersion}"; message += L10().serverApiRequired + ": ${_minApiVersion}";
@ -472,18 +488,86 @@ class InvenTreeAPI {
return false; return false;
} }
/** // At this point, we have a server which is responding
* Request user token information from the server return true;
* This is the stage that we check username:password credentials! }
*/
// Clear the existing token value
_token = "";
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 // Invalid response
if (!response.successful()) { if (!response.successful()) {
switch (response.statusCode) { switch (response.statusCode) {
case 401: case 401:
case 403: case 403:
@ -500,67 +584,29 @@ class InvenTreeAPI {
debug("Token request failed: STATUS ${response.statusCode}"); 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")) { 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( showServerError(
apiUrl, apiUrl,
L10().serverError, L10().tokenMissing,
L10().errorUserRoles, L10().tokenMissingFromResponse,
); );
return false;
} }
// Request plugin information (async) // Save the token to the user profile
result = await getPluginInformation(); userProfile.token = (data["token"] ?? "") as String;
if (!result) { debug("Received token from server: ${userProfile.token}");
showServerError(
apiUrl,
L10().serverError,
L10().errorPluginInfo
);
return false; await UserProfileDBManager().updateProfile(userProfile);
}
// Ok, probably pretty good...
if (_notification_timer == null) {
debug("starting notification timer");
_notification_timer = Timer.periodic(
Duration(seconds: 5),
(timer) {
_refreshNotifications();
});
}
return true;
return response;
} }
void disconnectFromServer() { void disconnectFromServer() {
@ -568,24 +614,25 @@ class InvenTreeAPI {
_connected = false; _connected = false;
_connecting = false; _connecting = false;
_token = "";
profile = null; profile = null;
// Clear received settings // Clear received settings
_globalSettings.clear(); _globalSettings.clear();
_userSettings.clear(); _userSettings.clear();
serverInfo.clear();
_connectionStatusChanged(); _connectionStatusChanged();
} }
// Public facing connection function
Future<bool> connectToServer() async { /* Public facing connection function.
*/
Future<bool> connectToServer(UserProfile prf) async {
// Ensure server is first disconnected // Ensure server is first disconnected
disconnectFromServer(); disconnectFromServer();
// Load selected profile profile = prf;
profile = await UserProfileDBManager().getSelectedProfile();
if (profile == null) { if (profile == null) {
showSnackIcon( showSnackIcon(
@ -596,12 +643,14 @@ class InvenTreeAPI {
return false; return false;
} }
_connecting = true; // Cancel notification timer
_notification_timer?.cancel();
_connecting = true;
_connectionStatusChanged(); _connectionStatusChanged();
_connected = await _connect(); // Perform the actual connection routine
_connected = await _connectToServer();
_connecting = false; _connecting = false;
if (_connected) { if (_connected) {
@ -610,6 +659,15 @@ class InvenTreeAPI {
icon: FontAwesomeIcons.server, icon: FontAwesomeIcons.server,
success: true, success: true,
); );
if (_notification_timer == null) {
debug("starting notification timer");
_notification_timer = Timer.periodic(
Duration(seconds: 5),
(timer) {
_refreshNotifications();
});
}
} }
_connectionStatusChanged(); _connectionStatusChanged();
@ -620,18 +678,13 @@ class InvenTreeAPI {
/* /*
* Request the user roles (permissions) from the InvenTree server * Request the user roles (permissions) from the InvenTree server
*/ */
Future<bool> getUserRoles() async { Future<bool> _fetchRoles() async {
roles.clear(); roles.clear();
debug("API: Requesting user role data"); debug("API: Requesting user role data");
// Next we request the permissions assigned to the current user final response = await get(_URL_ROLES, expectedStatusCode: 200);
// 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);
if (!response.successful()) { if (!response.successful()) {
return false; return false;
@ -645,12 +698,17 @@ class InvenTreeAPI {
return true; return true;
} else { } else {
showServerError(
apiUrl,
L10().serverError,
L10().errorUserRoles,
);
return false; return false;
} }
} }
// Request plugin information from the server // Request plugin information from the server
Future<bool> getPluginInformation() async { Future<bool> _fetchPlugins() async {
_plugins.clear(); _plugins.clear();
@ -690,7 +748,7 @@ class InvenTreeAPI {
if (roles[role] == null) { if (roles[role] == null) {
debug("checkPermission - role '$role' is null!"); debug("checkPermission - role '$role' is null!");
return true; return false;
} }
try { try {
@ -1045,7 +1103,14 @@ class InvenTreeAPI {
* @param method is the HTTP method e.g. "POST" / "PATCH" / "GET" etc; * @param method is the HTTP method e.g. "POST" / "PATCH" / "GET" etc;
* @param params is the request parameters * @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); var _url = makeApiUrl(url);
@ -1085,11 +1150,16 @@ class InvenTreeAPI {
try { try {
_request = await client.openUrl(method, _uri).timeout(Duration(seconds: 10)); _request = await client.openUrl(method, _uri).timeout(Duration(seconds: 10));
// Set headers // Default headers
defaultHeaders().forEach((key, value) { defaultHeaders().forEach((key, value) {
_request?.headers.set(key, value); _request?.headers.set(key, value);
}); });
// Custom headers
headers.forEach((key, value) {
_request?.headers.set(key, value);
});
return _request; return _request;
} on SocketException catch (error) { } on SocketException catch (error) {
debug("SocketException at ${url}: ${error.toString()}"); debug("SocketException at ${url}: ${error.toString()}");
@ -1262,12 +1332,13 @@ class InvenTreeAPI {
* Perform a HTTP GET request * Perform a HTTP GET request
* Returns a json object (or null if did not complete) * 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( HttpClientRequest? request = await apiRequest(
url, url,
"GET", "GET",
urlParams: params, urlParams: params,
headers: headers,
); );
@ -1334,7 +1405,10 @@ class InvenTreeAPI {
Map<String, String> defaultHeaders() { Map<String, String> defaultHeaders() {
Map<String, String> headers = {}; Map<String, String> headers = {};
headers[HttpHeaders.authorizationHeader] = _authorizationHeader(); if (hasToken) {
headers[HttpHeaders.authorizationHeader] = _authorizationHeader();
}
headers[HttpHeaders.acceptHeader] = "application/json"; headers[HttpHeaders.acceptHeader] = "application/json";
headers[HttpHeaders.contentTypeHeader] = "application/json"; headers[HttpHeaders.contentTypeHeader] = "application/json";
headers[HttpHeaders.acceptLanguageHeader] = currentLocale; headers[HttpHeaders.acceptLanguageHeader] = currentLocale;
@ -1342,11 +1416,10 @@ class InvenTreeAPI {
return headers; return headers;
} }
// Construct a token authorization header
String _authorizationHeader() { String _authorizationHeader() {
if (_token.isNotEmpty) { if (token.isNotEmpty) {
return "Token $_token"; return "Token ${token}";
} else if (profile != null) {
return "Basic " + base64Encode(utf8.encode("${profile?.username}:${profile?.password}"));
} else { } else {
return ""; return "";
} }
@ -1579,3 +1652,5 @@ class InvenTreeAPI {
}); });
} }
} }

View File

@ -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 { Future<List<InvenTreePOLineItem>> getLineItems() async {

View File

@ -44,7 +44,7 @@ Future<Map<String, dynamic>> getDeviceInfo() async {
"hardware": androidDeviceInfo.hardware, "hardware": androidDeviceInfo.hardware,
"manufacturer": androidDeviceInfo.manufacturer, "manufacturer": androidDeviceInfo.manufacturer,
"product": androidDeviceInfo.product, "product": androidDeviceInfo.product,
"version": androidDeviceInfo.version.release, "systemVersion": androidDeviceInfo.version.release,
"supported32BitAbis": androidDeviceInfo.supported32BitAbis, "supported32BitAbis": androidDeviceInfo.supported32BitAbis,
"supported64BitAbis": androidDeviceInfo.supported64BitAbis, "supported64BitAbis": androidDeviceInfo.supported64BitAbis,
"supportedAbis": androidDeviceInfo.supportedAbis, "supportedAbis": androidDeviceInfo.supportedAbis,
@ -57,7 +57,8 @@ Future<Map<String, dynamic>> getDeviceInfo() async {
Map<String, dynamic> getServerInfo() => { Map<String, dynamic> getServerInfo() => {
"version": InvenTreeAPI().version, "version": InvenTreeAPI().serverVersion,
"apiVersion": InvenTreeAPI().apiVersion,
}; };

View File

@ -591,6 +591,15 @@
"locationUpdated": "Stock location updated", "locationUpdated": "Stock location updated",
"@locationUpdated": {}, "@locationUpdated": {},
"login": "Login",
"@login": {},
"loginEnter": "Enter login details",
"@loginEnter": {},
"loginEnterDetails": "Username and password are not stored locally",
"@loginEnterDetails": {},
"link": "Link", "link": "Link",
"@link": {}, "@link": {},
@ -795,6 +804,9 @@
"profileDelete": "Delete Server Profile", "profileDelete": "Delete Server Profile",
"@profileDelete": {}, "@profileDelete": {},
"profileLogout": "Logout Profile",
"@profileLogout": {},
"profileName": "Profile Name", "profileName": "Profile Name",
"@profileName": {}, "@profileName": {},

View File

@ -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( tiles.add(
ListTile( ListTile(
title: Text(L10().version), 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), leading: FaIcon(FontAwesomeIcons.circleInfo),
) )
); );
@ -107,13 +115,13 @@ class InvenTreeAboutWidget extends StatelessWidget {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().serverInstance), 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), leading: FaIcon(FontAwesomeIcons.server),
) )
); );
// Display extra tile if the server supports plugins // Display extra tile if the server supports plugins
if (InvenTreeAPI().pluginsEnabled()) { if (InvenTreeAPI().pluginsEnabled) {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().pluginSupport), title: Text(L10().pluginSupport),

View File

@ -1,295 +1,117 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:font_awesome_flutter/font_awesome_flutter.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/app_colors.dart";
import "package:inventree/widget/dialogs.dart"; import "package:inventree/user_profile.dart";
import "package:inventree/widget/spinner.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/api.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 @override
_InvenTreeLoginSettingsState createState() => _InvenTreeLoginSettingsState(); _InvenTreeLoginState createState() => _InvenTreeLoginState();
} }
class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> { class _InvenTreeLoginState extends State<InvenTreeLoginWidget> {
_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();
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
String name = "";
String server = "";
String username = ""; String username = "";
String password = ""; String password = "";
bool _obscured = true; 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 @override
Widget build(BuildContext context) { 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.profile == null ? L10().profileAdd : L10().profileEdit), title: Text(L10().login),
actions: [ actions: [
IconButton( IconButton(
icon: FaIcon(FontAwesomeIcons.floppyDisk), icon: FaIcon(FontAwesomeIcons.arrowRightToBracket, color: COLOR_SUCCESS),
onPressed: () async { onPressed: () async {
if (formKey.currentState!.validate()) { _doLogin(context);
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();
}
}, },
) )
] ]
@ -302,79 +124,14 @@ class _ProfileEditState extends State<ProfileEditWidget> {
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
...before,
TextFormField( TextFormField(
decoration: InputDecoration( decoration: InputDecoration(
labelText: L10().profileName, labelText: L10().username,
labelStyle: TextStyle(fontWeight: FontWeight.bold), labelStyle: TextStyle(fontWeight: FontWeight.bold),
hintText: L10().enterUsername
), ),
initialValue: widget.profile?.name ?? "", initialValue: "",
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;
},
),
TextFormField(
decoration: InputDecoration(
labelText: L10().username,
labelStyle: TextStyle(fontWeight: FontWeight.bold),
hintText: L10().enterUsername
),
initialValue: widget.profile?.username ?? "",
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
onSaved: (value) { onSaved: (value) {
username = value?.trim() ?? ""; username = value?.trim() ?? "";
@ -388,39 +145,41 @@ class _ProfileEditState extends State<ProfileEditWidget> {
}, },
), ),
TextFormField( TextFormField(
decoration: InputDecoration( decoration: InputDecoration(
labelText: L10().password, labelText: L10().password,
labelStyle: TextStyle(fontWeight: FontWeight.bold), labelStyle: TextStyle(fontWeight: FontWeight.bold),
hintText: L10().enterPassword, hintText: L10().enterPassword,
suffixIcon: IconButton( suffixIcon: IconButton(
icon: _obscured ? FaIcon(FontAwesomeIcons.eye) : FaIcon(FontAwesomeIcons.solidEyeSlash), icon: _obscured ? FaIcon(FontAwesomeIcons.eye) : FaIcon(FontAwesomeIcons.solidEyeSlash),
onPressed: () { onPressed: () {
setState(() { setState(() {
_obscured = !_obscured; _obscured = !_obscured;
}); });
}, },
),
), ),
), initialValue: "",
initialValue: widget.profile?.password ?? "", keyboardType: TextInputType.visiblePassword,
keyboardType: TextInputType.visiblePassword, obscureText: _obscured,
obscureText: _obscured, onSaved: (value) {
onSaved: (value) { password = value?.trim() ?? "";
password = value ?? ""; },
}, validator: (value) {
validator: (value) { if (value == null || value.trim().isEmpty) {
if (value == null || value.trim().isEmpty) { return L10().passwordEmpty;
return L10().passwordEmpty; }
}
return null; return null;
} }
) ),
] ...after,
],
), ),
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
), )
) )
); );
} }
} }

View 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),
),
)
);
}
}

View File

@ -9,7 +9,7 @@ import "package:inventree/settings/about.dart";
import "package:inventree/settings/app_settings.dart"; import "package:inventree/settings/app_settings.dart";
import "package:inventree/settings/barcode_settings.dart"; import "package:inventree/settings/barcode_settings.dart";
import "package:inventree/settings/home_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"; import "package:inventree/settings/part_settings.dart";
@ -51,7 +51,7 @@ class _InvenTreeSettingsState extends State<InvenTreeSettingsWidget> {
subtitle: Text(L10().configureServer), subtitle: Text(L10().configureServer),
leading: FaIcon(FontAwesomeIcons.server, color: COLOR_ACTION), leading: FaIcon(FontAwesomeIcons.server, color: COLOR_ACTION),
onTap: () { onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => InvenTreeLoginSettingsWidget())); Navigator.push(context, MaterialPageRoute(builder: (context) => InvenTreeSelectServerWidget()));
}, },
), ),
ListTile( ListTile(

View File

@ -10,20 +10,21 @@ class UserProfile {
this.key, this.key,
this.name = "", this.name = "",
this.server = "", this.server = "",
this.username = "", this.token = "",
this.password = "",
this.selected = false, this.selected = false,
}); });
factory UserProfile.fromJson(int key, Map<String, dynamic> json, bool isSelected) => UserProfile( factory UserProfile.fromJson(int key, Map<String, dynamic> json, bool isSelected) => UserProfile(
key: key, key: key,
name: json["name"] as String, name: (json["name"] ?? "") as String,
server: json["server"] as String, server: (json["server"] ?? "") as String,
username: json["username"] as String, token: (json["token"] ?? "") as String,
password: json["password"] as String,
selected: isSelected, selected: isSelected,
); );
// Return true if this profile has a token
bool get hasToken => token.isNotEmpty;
// ID of the profile // ID of the profile
int? key; int? key;
@ -33,11 +34,8 @@ class UserProfile {
// Base address of the InvenTree server // Base address of the InvenTree server
String server = ""; String server = "";
// Username // API token
String username = ""; String token = "";
// Password
String password = "";
bool selected = false; bool selected = false;
@ -47,13 +45,12 @@ class UserProfile {
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
"name": name, "name": name,
"server": server, "server": server,
"username": username, "token": token,
"password": password,
}; };
@override @override
String toString() { String toString() {
return "<${key}> ${name} : ${server} - ${username}:${password}"; return "<${key}> ${name} : ${server}";
} }
} }
@ -88,7 +85,7 @@ class UserProfileDBManager {
*/ */
Future<bool> addProfile(UserProfile profile) async { 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"); debug("addProfile() : Profile missing required values - not adding to database");
return false; return false;
} }
@ -118,7 +115,7 @@ class UserProfileDBManager {
Future<bool> updateProfile(UserProfile profile) async { Future<bool> updateProfile(UserProfile profile) async {
// Prevent invalid profile data from being updated // 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"); debug("updateProfile() : Profile missing required values - not updating");
return false; return false;
} }
@ -204,8 +201,6 @@ class UserProfileDBManager {
UserProfile demoProfile = UserProfile( UserProfile demoProfile = UserProfile(
name: "InvenTree Demo", name: "InvenTree Demo",
server: "https://demo.inventree.org", server: "https://demo.inventree.org",
username: "allaccess",
password: "nolimits",
); );
await addProfile(demoProfile); await addProfile(demoProfile);
@ -217,6 +212,26 @@ class UserProfileDBManager {
return profileList; 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) * Retrieve a profile by name (or null if no match exists)
*/ */

View File

@ -235,50 +235,9 @@ Future<void> showServerError(String url, String title, String description) async
*/ */
Future<void> showStatusCodeError(String url, int status, {String details=""}) 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}"; 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) { if (details.isNotEmpty) {
extra += "\n"; extra += "\n";
extra += details; 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 * Displays a message indicating that the server timed out on a certain request
*/ */

View File

@ -8,7 +8,7 @@ import "package:inventree/api.dart";
import "package:inventree/app_colors.dart"; import "package:inventree/app_colors.dart";
import "package:inventree/preferences.dart"; import "package:inventree/preferences.dart";
import "package:inventree/l10.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/user_profile.dart";
import "package:inventree/widget/category_display.dart"; import "package:inventree/widget/category_display.dart";
@ -119,7 +119,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> with BaseWidgetPr
void _selectProfile() { void _selectProfile() {
Navigator.push( Navigator.push(
context, MaterialPageRoute(builder: (context) => InvenTreeLoginSettingsWidget()) context, MaterialPageRoute(builder: (context) => InvenTreeSelectServerWidget())
).then((context) { ).then((context) {
// Once we return // Once we return
_loadProfile(); _loadProfile();
@ -147,7 +147,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> with BaseWidgetPr
if (!InvenTreeAPI().isConnected() && !InvenTreeAPI().isConnecting()) { if (!InvenTreeAPI().isConnected() && !InvenTreeAPI().isConnecting()) {
// Attempt server connection // Attempt server connection
InvenTreeAPI().connectToServer().then((result) { InvenTreeAPI().connectToServer(_profile!).then((result) {
if (mounted) { if (mounted) {
setState(() {}); setState(() {});
} }

View File

@ -17,37 +17,11 @@ void main() {
setUp(() async { setUp(() async {
if (! await UserProfileDBManager().profileNameExists("Test Profile")) { await setupServerProfile(select: true);
// 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);
}
// Ensure the profile is selected // Ensure the profile is selected
assert(! await UserProfileDBManager().selectProfileByName("Missing Profile")); 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(); var api = InvenTreeAPI();
// Incorrect server address // Incorrect server address
var profile = await UserProfileDBManager().getSelectedProfile(); var profile = await setupServerProfile();
assert(profile != null); profile.server = "http://localhost:5555";
if (profile != null) { bool result = await api.connectToServer(profile);
profile.server = "http://localhost:5555"; assert(!result);
await UserProfileDBManager().updateProfile(profile);
bool result = await api.connectToServer(); debugContains("SocketException at");
assert(!result);
debugContains("SocketException at"); // Test incorrect login details
profile.server = testServerAddress;
// Test incorrect login details final response = await api.fetchToken(profile, "baduser", "badpassword");
profile.server = "http://localhost:12345"; assert(!response.successful());
profile.username = "invalidusername";
await UserProfileDBManager().updateProfile(profile); debugContains("Token request failed");
await api.connectToServer(); assert(!api.checkConnection());
assert(!result);
debugContains("Token request failed"); debugContains("Token request failed: STATUS 401");
debugContains("showSnackIcon: 'Not Connected'");
assert(!api.checkConnection()); });
debugContains("Token request failed: STATUS 401"); test("Bad Token", () async {
debugContains("showSnackIcon: 'Not Connected'"); // Test that login fails with a bad token
var profile = await setupServerProfile();
} else { profile.token = "bad-token";
assert(false);
}
bool result = await InvenTreeAPI().connectToServer(profile);
assert(!result);
}); });
test("Login Success", () async { test("Login Success", () async {
// Test that we can login to the server successfully // Test that we can login to the server successfully
var api = InvenTreeAPI(); var api = InvenTreeAPI();
// Attempt to connect final profile = await setupServerProfile(select: true, fetchToken: true);
final bool result = await api.connectToServer(); assert(profile.hasToken);
// Now, connect to the server
bool result = await api.connectToServer(profile);
// Check expected values // Check expected values
assert(result); assert(result);
assert(api.hasToken); assert(api.hasToken);
expect(api.baseUrl, equals("http://localhost:12345/"));
expect(api.baseUrl, equals(testServerAddress));
assert(api.hasToken);
assert(api.isConnected()); assert(api.isConnected());
assert(!api.isConnecting()); assert(!api.isConnecting());
assert(api.checkConnection()); assert(api.checkConnection());
@ -127,7 +105,8 @@ void main() {
// Test server version information // Test server version information
var api = InvenTreeAPI(); var api = InvenTreeAPI();
assert(await api.connectToServer()); final profile = await setupServerProfile(fetchToken: true);
assert(await api.connectToServer(profile));
// Check supported functions // Check supported functions
assert(api.apiVersion >= 50); assert(api.apiVersion >= 50);
@ -135,12 +114,15 @@ void main() {
assert(api.supportsNotifications); assert(api.supportsNotifications);
assert(api.supportsPoReceive); assert(api.supportsPoReceive);
// Ensure we can request (and receive) user roles assert(api.serverInstance.isNotEmpty);
assert(await api.getUserRoles()); assert(api.serverVersion.isNotEmpty);
// Ensure we can have user role data
assert(api.roles.isNotEmpty);
// Check available permissions // Check available permissions
assert(api.checkPermission("part", "change")); assert(api.checkPermission("part", "change"));
assert(api.checkPermission("stocklocation", "delete")); assert(api.checkPermission("stock_location", "delete"));
assert(!api.checkPermission("part", "weirdpermission")); assert(!api.checkPermission("part", "weirdpermission"));
assert(api.checkPermission("blah", "bloo")); assert(api.checkPermission("blah", "bloo"));

View File

@ -10,7 +10,6 @@ import "package:flutter_test/flutter_test.dart";
import "package:inventree/api.dart"; import "package:inventree/api.dart";
import "package:inventree/barcode/barcode.dart"; import "package:inventree/barcode/barcode.dart";
import "package:inventree/helpers.dart"; import "package:inventree/helpers.dart";
import "package:inventree/user_profile.dart";
import "package:inventree/inventree/part.dart"; import "package:inventree/inventree/part.dart";
import "package:inventree/inventree/stock.dart"; import "package:inventree/inventree/stock.dart";
@ -23,26 +22,7 @@ void main() {
// Connect to the server // Connect to the server
setUpAll(() async { setUpAll(() async {
final prf = await UserProfileDBManager().getProfileByName("Test Profile"); await connectToTestServer();
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());
}); });
setUp(() async { setUp(() async {
@ -91,8 +71,8 @@ void main() {
test("Scan Into Location", () async { test("Scan Into Location", () async {
final item = await InvenTreeStockItem().get(1) as InvenTreeStockItem?; final item = await InvenTreeStockItem().get(1) as InvenTreeStockItem?;
assert(item != null); assert(item != null);
assert(item!.pk == 1); assert(item!.pk == 1);
var handler = StockItemScanIntoLocationHandler(item!); var handler = StockItemScanIntoLocationHandler(item!);

View File

@ -5,7 +5,6 @@
import "package:test/test.dart"; import "package:test/test.dart";
import "package:inventree/api.dart"; import "package:inventree/api.dart";
import "package:inventree/user_profile.dart";
import "package:inventree/inventree/model.dart"; import "package:inventree/inventree/model.dart";
import "package:inventree/inventree/part.dart"; import "package:inventree/inventree/part.dart";
@ -16,16 +15,7 @@ void main() {
setupTestEnv(); setupTestEnv();
setUp(() async { setUp(() async {
await UserProfileDBManager().addProfile(UserProfile( await connectToTestServer();
name: "Test Profile",
server: "http://localhost:12345",
username: "testuser",
password: "testpassword",
selected: true,
));
assert(await UserProfileDBManager().selectProfileByName("Test Profile"));
assert(await InvenTreeAPI().connectToServer());
}); });
group("Category Tests:", () { group("Category Tests:", () {

View File

@ -1,6 +1,8 @@
import "package:flutter/services.dart"; import "package:flutter/services.dart";
import "package:flutter_test/flutter_test.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 // This is the same as the following issue except it keeps the http client
// TestWidgetsFlutterBinding.ensureInitialized(); // TestWidgetsFlutterBinding.ensureInitialized();
@ -19,4 +21,78 @@ void setupTestEnv() {
.setMockMethodCallHandler(channel, (MethodCall methodCall) async { .setMockMethodCallHandler(channel, (MethodCall methodCall) async {
return "."; 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));
} }

View File

@ -27,10 +27,8 @@ void main() {
// Now, create one! // Now, create one!
bool result = await UserProfileDBManager().addProfile(UserProfile( bool result = await UserProfileDBManager().addProfile(UserProfile(
name: "Test Profile", name: testServerName,
username: "testuser", server: testServerAddress,
password: "testpassword""",
server: "http://localhost:12345",
selected: true, selected: true,
)); ));
@ -62,20 +60,15 @@ void main() {
test("Add Invalid Profiles", () async { test("Add Invalid Profiles", () async {
// Add a profile with missing data // Add a profile with missing data
bool result = await UserProfileDBManager().addProfile( bool result = await UserProfileDBManager().addProfile(
UserProfile( UserProfile()
username: "what",
password: "why",
)
); );
expect(result, equals(false)); expect(result, equals(false));
// Add a profile with a name that already exists // Add a profile with a new name
result = await UserProfileDBManager().addProfile( result = await UserProfileDBManager().addProfile(
UserProfile( UserProfile(
name: "Test Profile", name: "Another Test Profile",
username: "xyz",
password: "hunter42",
) )
); );
@ -84,14 +77,14 @@ void main() {
// Check that the number of protocols available is still the same // Check that the number of protocols available is still the same
var profiles = await UserProfileDBManager().getAllProfiles(); var profiles = await UserProfileDBManager().getAllProfiles();
expect(profiles.length, equals(1)); expect(profiles.length, equals(2));
}); });
test("Profile Name Check", () async { test("Profile Name Check", () async {
bool result = await UserProfileDBManager().profileNameExists("doesnotexist"); bool result = await UserProfileDBManager().profileNameExists("doesnotexist");
expect(result, equals(false)); expect(result, equals(false));
result = await UserProfileDBManager().profileNameExists("Test Profile"); result = await UserProfileDBManager().profileNameExists("Test Server");
expect(result, equals(true)); expect(result, equals(true));
}); });
@ -104,23 +97,16 @@ void main() {
if (prf != null) { if (prf != null) {
UserProfile p = prf; UserProfile p = prf;
expect(p.name, equals("Test Profile")); expect(p.name, equals(testServerName));
expect(p.username, equals("testuser")); expect(p.server, equals(testServerAddress));
expect(p.password, equals("testpassword"));
expect(p.server, equals("http://localhost:12345"));
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 // Test that we can update the profile
p.name = "different name"; p.name = "different name";
bool result = await UserProfileDBManager().updateProfile(p); bool result = await UserProfileDBManager().updateProfile(p);
expect(result, equals(true)); expect(result, equals(true));
// Trying to update with an invalid value will fail!
p.password = "";
result = await UserProfileDBManager().updateProfile(p);
expect(result, equals(false));
} }
}); });
}); });