From 76b6191a675026eb3849e8afc0f0e6a051f7834b Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Mon, 23 Oct 2023 01:29:16 +1100
Subject: [PATCH] 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
---
 .github/workflows/ci.yaml         |   5 +-
 assets/release_notes.md           |   3 +
 lib/api.dart                      | 383 +++++++++++++----------
 lib/inventree/purchase_order.dart |  12 +-
 lib/inventree/sentry.dart         |   5 +-
 lib/l10n/app_en.arb               |  12 +
 lib/settings/about.dart           |  14 +-
 lib/settings/login.dart           | 485 ++++++++----------------------
 lib/settings/select_server.dart   | 430 ++++++++++++++++++++++++++
 lib/settings/settings.dart        |   4 +-
 lib/user_profile.dart             |  51 ++--
 lib/widget/dialogs.dart           |  78 +++--
 lib/widget/home.dart              |   6 +-
 test/api_test.dart                |  94 +++---
 test/barcode_test.dart            |  24 +-
 test/models_test.dart             |  12 +-
 test/setup.dart                   |  76 +++++
 test/user_profile_test.dart       |  34 +--
 18 files changed, 1023 insertions(+), 705 deletions(-)
 create mode 100644 lib/settings/select_server.dart

diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 02111c89..4e3e3718 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -18,9 +18,6 @@ env:
   INVENTREE_ADMIN_USER: testuser
   INVENTREE_ADMIN_PASSWORD: testpassword
   INVENTREE_ADMIN_EMAIL: test@test.com
-  INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345
-  INVENTREE_PYTHON_TEST_USERNAME: testuser
-  INVENTREE_PYTHON_TEST_PASSWORD: testpassword
 jobs:
 
   test:
@@ -64,7 +61,7 @@ jobs:
           invoke install
           invoke migrate
           invoke import-fixtures
-          invoke server -a 127.0.0.1:12345 &
+          invoke server -a 127.0.0.1:8000 &
           invoke wait
           sleep 30
       - name: Unit Tests
diff --git a/assets/release_notes.md b/assets/release_notes.md
index 31e37f51..1bdcb63f 100644
--- a/assets/release_notes.md
+++ b/assets/release_notes.md
@@ -2,6 +2,9 @@
 ---
 
 - Add ability to scan in received items using supplier barcodes
+- Store API token, rather than username:password
+- Ensure that user will lose access if token is revoked by server
+
 
 ### 0.12.8 - September 2023
 ---
diff --git a/lib/api.dart b/lib/api.dart
index 6ea78805..b8a2d7b1 100644
--- a/lib/api.dart
+++ b/lib/api.dart
@@ -192,16 +192,13 @@ class InvenTreeAPI {
   bool _strictHttps = false;
 
   // Endpoint for requesting an API token
-  static const _URL_GET_TOKEN = "user/token/";
-
-  static const _URL_GET_ROLES = "user/roles/";
-
-  // Base URL for InvenTree API e.g. http://192.168.120.10:8000
-  String _BASE_URL = "";
+  static const _URL_TOKEN = "user/token/";
+  static const _URL_ROLES = "user/roles/";
+  static const _URL_ME = "user/me/";
 
   // Accessors for various url endpoints
   String get baseUrl {
-    String url = _BASE_URL;
+    String url = profile?.server ?? "";
 
     if (!url.endsWith("/")) {
       url += "/";
@@ -242,21 +239,22 @@ class InvenTreeAPI {
   // Available user roles (permissions) are loaded when connecting to the server
   Map<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,33 +371,68 @@ class InvenTreeAPI {
     return !isConnected() && _connecting;
   }
 
-  /*
-   * Connect to the remote InvenTree server:
-   *
-   * - Check that the InvenTree server exists
-   * - Request user token from the server
-   * - Request user roles from the server
-   */
-  Future<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,
-        success: false
+          L10().incompleteDetails,
+          icon: FontAwesomeIcons.circleExclamation,
+          success: false
       );
       return false;
     }
@@ -414,27 +441,24 @@ class InvenTreeAPI {
       address = address + "/";
     }
 
-    _BASE_URL = address;
+    // Cache the "strictHttps" setting, so we can use it later without async requirement
+    _strictHttps = await InvenTreeSettingsManager().getValue(INV_STRICT_HTTPS, false) as bool;
 
-    // Clear the list of available plugins
-    _plugins.clear();
+    debug("Connecting to ${apiUrl}");
 
-    debug("Connecting to ${apiUrl} -> username=${username}");
-
-    APIResponse response;
-
-    response = await get("", expectedStatusCode: 200);
+    APIResponse response = await get("", expectedStatusCode: 200);
 
     if (!response.successful()) {
+      debug("Server returned invalid response: ${response.statusCode}");
       showStatusCodeError(apiUrl, response.statusCode, details: response.data.toString());
       return false;
     }
 
-    var data = response.asMap();
+    Map<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,67 +584,29 @@ class InvenTreeAPI {
 
       debug("Token request failed: STATUS ${response.statusCode}");
 
-      return false;
+      if (response.data != null) {
+        debug("Response data: ${response.data.toString()}");
+      }
     }
 
-    data = response.asMap();
+    final data = response.asMap();
 
     if (!data.containsKey("token")) {
-      showServerError(
-          apiUrl,
-          L10().tokenMissing,
-          L10().tokenMissingFromResponse,
-      );
-
-      return false;
-    }
-
-    // Return the received token
-    _token = (data["token"] ?? "") as String;
-
-    debug("Received token from server");
-
-    bool result = false;
-
-    // Request user role information (async)
-    result = await getUserRoles();
-
-    if (!result) {
       showServerError(
         apiUrl,
-        L10().serverError,
-        L10().errorUserRoles,
+        L10().tokenMissing,
+        L10().tokenMissingFromResponse,
       );
-
-      return false;
     }
 
-    // Request plugin information (async)
-    result = await getPluginInformation();
+    // Save the token to the user profile
+    userProfile.token = (data["token"] ?? "") as String;
 
-    if (!result) {
-      showServerError(
-        apiUrl,
-        L10().serverError,
-        L10().errorPluginInfo
-      );
+    debug("Received token from server: ${userProfile.token}");
 
-      return false;
-    }
-
-    // Ok, probably pretty good...
-
-    if (_notification_timer == null) {
-      debug("starting notification timer");
-      _notification_timer = Timer.periodic(
-        Duration(seconds: 5),
-        (timer) {
-            _refreshNotifications();
-        });
-    }
-
-    return true;
+    await UserProfileDBManager().updateProfile(userProfile);
 
+    return response;
   }
 
   void disconnectFromServer() {
@@ -568,24 +614,25 @@ class InvenTreeAPI {
 
     _connected = false;
     _connecting = false;
-    _token = "";
     profile = null;
 
     // Clear received settings
     _globalSettings.clear();
     _userSettings.clear();
 
+    serverInfo.clear();
     _connectionStatusChanged();
   }
 
-  // Public facing connection function
-  Future<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 = {};
 
-    headers[HttpHeaders.authorizationHeader] = _authorizationHeader();
+    if (hasToken) {
+      headers[HttpHeaders.authorizationHeader] = _authorizationHeader();
+    }
+
     headers[HttpHeaders.acceptHeader] = "application/json";
     headers[HttpHeaders.contentTypeHeader] = "application/json";
     headers[HttpHeaders.acceptLanguageHeader] = currentLocale;
@@ -1342,11 +1416,10 @@ class InvenTreeAPI {
     return headers;
   }
 
+  // Construct a token authorization header
   String _authorizationHeader() {
-    if (_token.isNotEmpty) {
-      return "Token $_token";
-    } else if (profile != null) {
-      return "Basic " + base64Encode(utf8.encode("${profile?.username}:${profile?.password}"));
+    if (token.isNotEmpty) {
+      return "Token ${token}";
     } else {
       return "";
     }
@@ -1579,3 +1652,5 @@ class InvenTreeAPI {
     });
   }
 }
+
+
diff --git a/lib/inventree/purchase_order.dart b/lib/inventree/purchase_order.dart
index 199bc15e..defb9f8a 100644
--- a/lib/inventree/purchase_order.dart
+++ b/lib/inventree/purchase_order.dart
@@ -131,7 +131,17 @@ class InvenTreePurchaseOrder extends InvenTreeModel {
     }
   }
 
-  String get totalPriceCurrency => getString("total_price_currency");
+  // Return the currency for this order
+  // Note that the nomenclature in the API changed at some point
+  String get totalPriceCurrency {
+    if (jsondata.containsKey("order_currency")) {
+      return getString("order_currency");
+    } else if (jsondata.containsKey("total_price_currency")) {
+      return getString("total_price_currency");
+    } else {
+      return "";
+    }
+  }
 
   Future<List<InvenTreePOLineItem>> getLineItems() async {
 
diff --git a/lib/inventree/sentry.dart b/lib/inventree/sentry.dart
index 1d82ff01..4ca9cf41 100644
--- a/lib/inventree/sentry.dart
+++ b/lib/inventree/sentry.dart
@@ -44,7 +44,7 @@ Future<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,
 };
 
 
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 673d284f..4d858d0d 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -591,6 +591,15 @@
   "locationUpdated": "Stock location updated",
   "@locationUpdated": {},
 
+  "login": "Login",
+  "@login": {},
+
+  "loginEnter": "Enter login details",
+  "@loginEnter": {},
+
+  "loginEnterDetails": "Username and password are not stored locally",
+  "@loginEnterDetails": {},
+
   "link": "Link",
   "@link": {},
 
@@ -795,6 +804,9 @@
   "profileDelete": "Delete Server Profile",
   "@profileDelete": {},
 
+  "profileLogout": "Logout Profile",
+  "@profileLogout": {},
+
   "profileName": "Profile Name",
   "@profileName": {},
 
diff --git a/lib/settings/about.dart b/lib/settings/about.dart
index 84578e03..3f02f6fc 100644
--- a/lib/settings/about.dart
+++ b/lib/settings/about.dart
@@ -96,10 +96,18 @@ class InvenTreeAboutWidget extends StatelessWidget {
           )
       );
 
+      tiles.add(
+        ListTile(
+          title: Text(L10().username),
+          subtitle: Text(InvenTreeAPI().username),
+          leading: InvenTreeAPI().username.isNotEmpty ? FaIcon(FontAwesomeIcons.user) : FaIcon(FontAwesomeIcons.userSlash, color: COLOR_DANGER),
+        )
+      );
+
       tiles.add(
         ListTile(
           title: Text(L10().version),
-          subtitle: Text(InvenTreeAPI().version.isNotEmpty ? InvenTreeAPI().version : L10().notConnected),
+          subtitle: Text(InvenTreeAPI().serverVersion.isNotEmpty ? InvenTreeAPI().serverVersion : L10().notConnected),
           leading: FaIcon(FontAwesomeIcons.circleInfo),
         )
       );
@@ -107,13 +115,13 @@ class InvenTreeAboutWidget extends StatelessWidget {
       tiles.add(
         ListTile(
           title: Text(L10().serverInstance),
-          subtitle: Text(InvenTreeAPI().instance.isNotEmpty ? InvenTreeAPI().instance : L10().notConnected),
+          subtitle: Text(InvenTreeAPI().serverInstance.isNotEmpty ? InvenTreeAPI().serverInstance : L10().notConnected),
           leading: FaIcon(FontAwesomeIcons.server),
         )
       );
 
       // Display extra tile if the server supports plugins
-      if (InvenTreeAPI().pluginsEnabled()) {
+      if (InvenTreeAPI().pluginsEnabled) {
         tiles.add(
           ListTile(
             title: Text(L10().pluginSupport),
diff --git a/lib/settings/login.dart b/lib/settings/login.dart
index 185a7797..57b1c161 100644
--- a/lib/settings/login.dart
+++ b/lib/settings/login.dart
@@ -1,295 +1,117 @@
+
 import "package:flutter/material.dart";
 import "package:font_awesome_flutter/font_awesome_flutter.dart";
-import "package:one_context/one_context.dart";
-
 import "package:inventree/app_colors.dart";
-import "package:inventree/widget/dialogs.dart";
-import "package:inventree/widget/spinner.dart";
+import "package:inventree/user_profile.dart";
 import "package:inventree/l10.dart";
 import "package:inventree/api.dart";
-import "package:inventree/user_profile.dart";
+import "package:inventree/widget/dialogs.dart";
+import "package:inventree/widget/progress.dart";
 
-class InvenTreeLoginSettingsWidget extends StatefulWidget {
+
+class InvenTreeLoginWidget extends StatefulWidget {
+
+  const InvenTreeLoginWidget(this.profile) : super();
+
+  final UserProfile profile;
 
   @override
-  _InvenTreeLoginSettingsState createState() => _InvenTreeLoginSettingsState();
+  _InvenTreeLoginState createState() => _InvenTreeLoginState();
+
 }
 
 
-class _InvenTreeLoginSettingsState extends State<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: [
+              ...before,
               TextFormField(
                 decoration: InputDecoration(
-                  labelText: L10().profileName,
-                  labelStyle: TextStyle(fontWeight: FontWeight.bold),
+                    labelText: L10().username,
+                    labelStyle: TextStyle(fontWeight: FontWeight.bold),
+                    hintText: L10().enterUsername
                 ),
-                initialValue: widget.profile?.name ?? "",
-                maxLines: 1,
-                keyboardType: TextInputType.text,
-                onSaved: (value) {
-                  name = value?.trim() ?? "";
-                },
-                validator: (value) {
-                  if (value == null || value.trim().isEmpty) {
-                    return L10().valueCannotBeEmpty;
-                  }
-
-                  return null;
-                }
-              ),
-              TextFormField(
-                decoration: InputDecoration(
-                  labelText: L10().server,
-                  labelStyle: TextStyle(fontWeight: FontWeight.bold),
-                  hintText: "http[s]://<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 ?? "",
+                initialValue: "",
                 keyboardType: TextInputType.text,
                 onSaved: (value) {
                   username = value?.trim() ?? "";
@@ -388,39 +145,41 @@ class _ProfileEditState extends State<ProfileEditWidget> {
                 },
               ),
               TextFormField(
-                decoration: InputDecoration(
-                  labelText: L10().password,
-                  labelStyle: TextStyle(fontWeight: FontWeight.bold),
-                  hintText: L10().enterPassword,
-                  suffixIcon: IconButton(
-                    icon: _obscured ? FaIcon(FontAwesomeIcons.eye) : FaIcon(FontAwesomeIcons.solidEyeSlash),
-                    onPressed: () {
-                      setState(() {
-                        _obscured = !_obscured;
-                      });
-                    },
+                  decoration: InputDecoration(
+                    labelText: L10().password,
+                    labelStyle: TextStyle(fontWeight: FontWeight.bold),
+                    hintText: L10().enterPassword,
+                    suffixIcon: IconButton(
+                      icon: _obscured ? FaIcon(FontAwesomeIcons.eye) : FaIcon(FontAwesomeIcons.solidEyeSlash),
+                      onPressed: () {
+                        setState(() {
+                          _obscured = !_obscured;
+                        });
+                      },
+                    ),
                   ),
-                ),
-                initialValue: widget.profile?.password ?? "",
-                keyboardType: TextInputType.visiblePassword,
-                obscureText: _obscured,
-                onSaved: (value) {
-                  password = value ?? "";
-                },
-                validator: (value) {
-                  if (value == null || value.trim().isEmpty) {
-                    return L10().passwordEmpty;
-                  }
+                  initialValue: "",
+                  keyboardType: TextInputType.visiblePassword,
+                  obscureText: _obscured,
+                  onSaved: (value) {
+                    password = value?.trim() ?? "";
+                  },
+                  validator: (value) {
+                    if (value == null || value.trim().isEmpty) {
+                      return L10().passwordEmpty;
+                    }
 
-                  return null;
-                }
-              )
-            ]
+                    return null;
+                  }
+              ),
+              ...after,
+            ],
           ),
           padding: EdgeInsets.all(16),
-        ),
+        )
       )
     );
+
   }
 
 }
\ No newline at end of file
diff --git a/lib/settings/select_server.dart b/lib/settings/select_server.dart
new file mode 100644
index 00000000..cd247b67
--- /dev/null
+++ b/lib/settings/select_server.dart
@@ -0,0 +1,430 @@
+import "package:flutter/material.dart";
+import "package:font_awesome_flutter/font_awesome_flutter.dart";
+import "package:inventree/settings/login.dart";
+import "package:one_context/one_context.dart";
+
+import "package:inventree/app_colors.dart";
+import "package:inventree/widget/dialogs.dart";
+import "package:inventree/widget/spinner.dart";
+import "package:inventree/l10.dart";
+import "package:inventree/api.dart";
+import "package:inventree/user_profile.dart";
+
+class InvenTreeSelectServerWidget extends StatefulWidget {
+
+  @override
+  _InvenTreeSelectServerState createState() => _InvenTreeSelectServerState();
+}
+
+
+class _InvenTreeSelectServerState extends State<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),
+        ),
+      )
+    );
+  }
+
+}
\ No newline at end of file
diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart
index 8c420718..dbf7e7e4 100644
--- a/lib/settings/settings.dart
+++ b/lib/settings/settings.dart
@@ -9,7 +9,7 @@ import "package:inventree/settings/about.dart";
 import "package:inventree/settings/app_settings.dart";
 import "package:inventree/settings/barcode_settings.dart";
 import "package:inventree/settings/home_settings.dart";
-import "package:inventree/settings/login.dart";
+import "package:inventree/settings/select_server.dart";
 import "package:inventree/settings/part_settings.dart";
 
 
@@ -51,7 +51,7 @@ class _InvenTreeSettingsState extends State<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(
diff --git a/lib/user_profile.dart b/lib/user_profile.dart
index e1b54467..9d17b065 100644
--- a/lib/user_profile.dart
+++ b/lib/user_profile.dart
@@ -10,20 +10,21 @@ class UserProfile {
     this.key,
     this.name = "",
     this.server = "",
-    this.username = "",
-    this.password = "",
+    this.token = "",
     this.selected = false,
   });
 
   factory UserProfile.fromJson(int key, Map<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)
    */
diff --git a/lib/widget/dialogs.dart b/lib/widget/dialogs.dart
index 1c86ef2f..88229c13 100644
--- a/lib/widget/dialogs.dart
+++ b/lib/widget/dialogs.dart
@@ -235,50 +235,9 @@ Future<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
  */
diff --git a/lib/widget/home.dart b/lib/widget/home.dart
index 5fa8a2e9..b62daf77 100644
--- a/lib/widget/home.dart
+++ b/lib/widget/home.dart
@@ -8,7 +8,7 @@ import "package:inventree/api.dart";
 import "package:inventree/app_colors.dart";
 import "package:inventree/preferences.dart";
 import "package:inventree/l10.dart";
-import "package:inventree/settings/login.dart";
+import "package:inventree/settings/select_server.dart";
 import "package:inventree/user_profile.dart";
 
 import "package:inventree/widget/category_display.dart";
@@ -119,7 +119,7 @@ class _InvenTreeHomePageState extends State<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(() {});
           }
diff --git a/test/api_test.dart b/test/api_test.dart
index 5990d161..a9c72910 100644
--- a/test/api_test.dart
+++ b/test/api_test.dart
@@ -17,37 +17,11 @@ void main() {
 
   setUp(() async {
 
-    if (! await UserProfileDBManager().profileNameExists("Test Profile")) {
-      // Create and select a profile to user
-
-      print("TEST: Creating profile for user 'testuser'");
-
-      await UserProfileDBManager().addProfile(UserProfile(
-        name: "Test Profile",
-        server: "http://localhost:12345",
-        username: "testuser",
-        password: "testpassword",
-        selected: true,
-      ));
-    }
-
-    var prf = await UserProfileDBManager().getSelectedProfile();
-
-    // Ensure that the server settings are correct by default,
-    // as they can get overwritten by subsequent tests
-
-    if (prf != null) {
-      prf.name = "Test Profile";
-      prf.server = "http://localhost:12345";
-      prf.username = "testuser";
-      prf.password = "testpassword";
-
-      await UserProfileDBManager().updateProfile(prf);
-    }
+    await setupServerProfile(select: true);
 
     // Ensure the profile is selected
     assert(! await UserProfileDBManager().selectProfileByName("Missing Profile"));
-    assert(await UserProfileDBManager().selectProfileByName("Test Profile"));
+    assert(await UserProfileDBManager().selectProfileByName(testServerName));
 
   });
 
@@ -71,53 +45,57 @@ void main() {
       var api = InvenTreeAPI();
 
       // Incorrect server address
-      var profile = await UserProfileDBManager().getSelectedProfile();
+      var profile = await setupServerProfile();
 
-      assert(profile != null);
+      profile.server = "http://localhost:5555";
 
-      if (profile != null) {
-        profile.server = "http://localhost:5555";
-        await UserProfileDBManager().updateProfile(profile);
+      bool result = await api.connectToServer(profile);
+      assert(!result);
 
-        bool result = await api.connectToServer();
-        assert(!result);
+      debugContains("SocketException at");
 
-        debugContains("SocketException at");
+      // Test incorrect login details
+      profile.server = testServerAddress;
 
-        // Test incorrect login details
-        profile.server = "http://localhost:12345";
-        profile.username = "invalidusername";
+      final response = await api.fetchToken(profile, "baduser", "badpassword");
+      assert(!response.successful());
 
-        await UserProfileDBManager().updateProfile(profile);
+      debugContains("Token request failed");
 
-        await api.connectToServer();
-        assert(!result);
+      assert(!api.checkConnection());
 
-        debugContains("Token request failed");
+      debugContains("Token request failed: STATUS 401");
+      debugContains("showSnackIcon: 'Not Connected'");
 
-        assert(!api.checkConnection());
+    });
 
-        debugContains("Token request failed: STATUS 401");
-        debugContains("showSnackIcon: 'Not Connected'");
+    test("Bad Token", () async {
+      // Test that login fails with a bad token
+      var profile = await setupServerProfile();
 
-      } else {
-        assert(false);
-      }
+      profile.token = "bad-token";
 
+      bool result = await InvenTreeAPI().connectToServer(profile);
+      assert(!result);
     });
 
     test("Login Success", () async {
       // Test that we can login to the server successfully
       var api = InvenTreeAPI();
 
-      // Attempt to connect
-      final bool result = await api.connectToServer();
+      final profile = await setupServerProfile(select: true, fetchToken: true);
+      assert(profile.hasToken);
+
+      // Now, connect to the server
+      bool result = await api.connectToServer(profile);
 
       // Check expected values
       assert(result);
       assert(api.hasToken);
-      expect(api.baseUrl, equals("http://localhost:12345/"));
 
+      expect(api.baseUrl, equals(testServerAddress));
+
+      assert(api.hasToken);
       assert(api.isConnected());
       assert(!api.isConnecting());
       assert(api.checkConnection());
@@ -127,7 +105,8 @@ void main() {
       // Test server version information
       var api = InvenTreeAPI();
 
-      assert(await api.connectToServer());
+      final profile = await setupServerProfile(fetchToken: true);
+      assert(await api.connectToServer(profile));
 
       // Check supported functions
       assert(api.apiVersion >= 50);
@@ -135,12 +114,15 @@ void main() {
       assert(api.supportsNotifications);
       assert(api.supportsPoReceive);
 
-      // Ensure we can request (and receive) user roles
-      assert(await api.getUserRoles());
+      assert(api.serverInstance.isNotEmpty);
+      assert(api.serverVersion.isNotEmpty);
+
+      // Ensure we can have user role data
+      assert(api.roles.isNotEmpty);
 
       // Check available permissions
       assert(api.checkPermission("part", "change"));
-      assert(api.checkPermission("stocklocation", "delete"));
+      assert(api.checkPermission("stock_location", "delete"));
       assert(!api.checkPermission("part", "weirdpermission"));
       assert(api.checkPermission("blah", "bloo"));
 
diff --git a/test/barcode_test.dart b/test/barcode_test.dart
index e28715b3..181e74d2 100644
--- a/test/barcode_test.dart
+++ b/test/barcode_test.dart
@@ -10,7 +10,6 @@ import "package:flutter_test/flutter_test.dart";
 import "package:inventree/api.dart";
 import "package:inventree/barcode/barcode.dart";
 import "package:inventree/helpers.dart";
-import "package:inventree/user_profile.dart";
 
 import "package:inventree/inventree/part.dart";
 import "package:inventree/inventree/stock.dart";
@@ -23,26 +22,7 @@ void main() {
 
   // Connect to the server
   setUpAll(() async {
-    final prf = await UserProfileDBManager().getProfileByName("Test Profile");
-
-    if (prf != null) {
-      await UserProfileDBManager().deleteProfile(prf);
-    }
-
-    bool result = await UserProfileDBManager().addProfile(
-      UserProfile(
-        name: "Test Profile",
-        server: "http://localhost:12345",
-        username: "testuser",
-        password: "testpassword",
-        selected: true,
-      ),
-    );
-
-    assert(result);
-
-    assert(await UserProfileDBManager().selectProfileByName("Test Profile"));
-    assert(await InvenTreeAPI().connectToServer());
+    await connectToTestServer();
   });
 
   setUp(() async {
@@ -91,8 +71,8 @@ void main() {
     test("Scan Into Location", () async {
 
       final item = await InvenTreeStockItem().get(1) as InvenTreeStockItem?;
-
       assert(item != null);
+
       assert(item!.pk == 1);
 
       var handler = StockItemScanIntoLocationHandler(item!);
diff --git a/test/models_test.dart b/test/models_test.dart
index c44ad5cd..5a82d474 100644
--- a/test/models_test.dart
+++ b/test/models_test.dart
@@ -5,7 +5,6 @@
 import "package:test/test.dart";
 
 import "package:inventree/api.dart";
-import "package:inventree/user_profile.dart";
 import "package:inventree/inventree/model.dart";
 import "package:inventree/inventree/part.dart";
 
@@ -16,16 +15,7 @@ void main() {
   setupTestEnv();
 
   setUp(() async {
-    await UserProfileDBManager().addProfile(UserProfile(
-      name: "Test Profile",
-      server: "http://localhost:12345",
-      username: "testuser",
-      password: "testpassword",
-      selected: true,
-    ));
-
-    assert(await UserProfileDBManager().selectProfileByName("Test Profile"));
-    assert(await InvenTreeAPI().connectToServer());
+    await connectToTestServer();
   });
 
   group("Category Tests:", () {
diff --git a/test/setup.dart b/test/setup.dart
index 1338f132..b9785b7d 100644
--- a/test/setup.dart
+++ b/test/setup.dart
@@ -1,6 +1,8 @@
 
 import "package:flutter/services.dart";
 import "package:flutter_test/flutter_test.dart";
+import "package:inventree/api.dart";
+import "package:inventree/user_profile.dart";
 
 // This is the same as the following issue except it keeps the http client
 // TestWidgetsFlutterBinding.ensureInitialized();
@@ -19,4 +21,78 @@ void setupTestEnv() {
       .setMockMethodCallHandler(channel, (MethodCall methodCall) async {
     return ".";
   });
+}
+
+// Accessors for default testing values
+const String testServerAddress = "http://localhost:8000/";
+const String testServerName = "Test Server";
+const String testUsername = "testuser";
+const String testPassword = "testpassword";
+
+
+/*
+ * Request an API token for the given profile
+ */
+Future<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));
 }
\ No newline at end of file
diff --git a/test/user_profile_test.dart b/test/user_profile_test.dart
index 1da201fa..0921d761 100644
--- a/test/user_profile_test.dart
+++ b/test/user_profile_test.dart
@@ -27,10 +27,8 @@ void main() {
 
     // Now, create one!
     bool result = await UserProfileDBManager().addProfile(UserProfile(
-      name: "Test Profile",
-      username: "testuser",
-      password: "testpassword""",
-      server: "http://localhost:12345",
+      name: testServerName,
+      server: testServerAddress,
       selected: true,
     ));
 
@@ -62,20 +60,15 @@ void main() {
     test("Add Invalid Profiles", () async {
       // Add a profile with missing data
       bool result = await UserProfileDBManager().addProfile(
-        UserProfile(
-          username: "what",
-          password: "why",
-        )
+        UserProfile()
       );
 
       expect(result, equals(false));
 
-      // Add a profile with a name that already exists
+      // Add a profile with a new name
       result = await UserProfileDBManager().addProfile(
         UserProfile(
-          name: "Test Profile",
-          username: "xyz",
-          password: "hunter42",
+          name: "Another Test Profile",
         )
       );
 
@@ -84,14 +77,14 @@ void main() {
       // Check that the number of protocols available is still the same
       var profiles = await UserProfileDBManager().getAllProfiles();
 
-      expect(profiles.length, equals(1));
+      expect(profiles.length, equals(2));
     });
 
     test("Profile Name Check", () async {
       bool result = await UserProfileDBManager().profileNameExists("doesnotexist");
       expect(result, equals(false));
 
-      result = await UserProfileDBManager().profileNameExists("Test Profile");
+      result = await UserProfileDBManager().profileNameExists("Test Server");
       expect(result, equals(true));
     });
 
@@ -104,23 +97,16 @@ void main() {
       if (prf != null) {
         UserProfile p = prf;
 
-        expect(p.name, equals("Test Profile"));
-        expect(p.username, equals("testuser"));
-        expect(p.password, equals("testpassword"));
-        expect(p.server, equals("http://localhost:12345"));
+        expect(p.name, equals(testServerName));
+        expect(p.server, equals(testServerAddress));
 
-        expect(p.toString(), equals("<${p.key}> Test Profile : http://localhost:12345 - testuser:testpassword"));
+        expect(p.toString(), equals("<${p.key}> Test Server : http://localhost:8000/"));
 
         // Test that we can update the profile
         p.name = "different name";
 
         bool result = await UserProfileDBManager().updateProfile(p);
         expect(result, equals(true));
-
-        // Trying to update with an invalid value will fail!
-        p.password = "";
-        result = await UserProfileDBManager().updateProfile(p);
-        expect(result, equals(false));
       }
     });
   });