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_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

View File

@ -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
---

View File

@ -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 {
});
}
}

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 {

View File

@ -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,
};

View File

@ -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": {},

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(
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),

View File

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

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/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(

View File

@ -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)
*/

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 {
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
*/

View File

@ -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(() {});
}

View File

@ -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"));

View File

@ -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!);

View File

@ -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:", () {

View File

@ -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));
}

View File

@ -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));
}
});
});