mirror of
https://github.com/inventree/inventree-app.git
synced 2026-09-09 03:10:14 +00:00
UI updates (#849)
* Updated dashboard - Display "outstanding" badges - Display "overdue" badges - Pull-to-refresh * Remove dead code * Refactor app colors * Add "pending shipments" icon * Remove custom spinner * Refactor error dialog * Updated redirect after login * Refactor login/account pages - Better UX and confirmation messages * Refactor API error messages * Improve token management - Secure storage - Handle session expiry - Per-profile HTTPS certificate checks * Improved error messages
This commit is contained in:
+219
-47
@@ -25,6 +25,7 @@ import "package:inventree/inventree/model.dart";
|
||||
import "package:inventree/inventree/notification.dart";
|
||||
import "package:inventree/inventree/status_codes.dart";
|
||||
import "package:inventree/inventree/sentry.dart";
|
||||
import "package:inventree/settings/login.dart";
|
||||
import "package:inventree/user_profile.dart";
|
||||
import "package:inventree/widget/dialogs.dart";
|
||||
import "package:inventree/widget/snacks.dart";
|
||||
@@ -191,8 +192,11 @@ class InvenTreeAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// Minimum required API version for server
|
||||
// 2023-03-04
|
||||
// Minimum required API version for server.
|
||||
// Deliberately kept low/permissive: individual features are gated by their
|
||||
// own apiVersion checks below (up to 496 at last count), so this floor
|
||||
// only needs to reject servers old enough to predate those checks
|
||||
// entirely, not enforce feature parity. Last reviewed 2026-07-06.
|
||||
static const _minApiVersion = 100;
|
||||
|
||||
bool _strictHttps = false;
|
||||
@@ -430,16 +434,34 @@ class InvenTreeAPI {
|
||||
}
|
||||
|
||||
if (!await _checkAuth()) {
|
||||
showServerError(
|
||||
_URL_ME,
|
||||
L10().serverNotConnected,
|
||||
L10().serverAuthenticationError,
|
||||
);
|
||||
final UserProfile? expiredProfile = profile;
|
||||
|
||||
// Invalidate the token
|
||||
if (profile != null) {
|
||||
profile!.token = "";
|
||||
await UserProfileDBManager().updateProfile(profile!);
|
||||
if (expiredProfile != null) {
|
||||
expiredProfile.token = "";
|
||||
await UserProfileDBManager().updateProfile(expiredProfile);
|
||||
}
|
||||
|
||||
if (expiredProfile != null) {
|
||||
showServerError(
|
||||
_URL_ME,
|
||||
L10().sessionExpired,
|
||||
"${L10().sessionExpiredDetail}\n${expiredProfile.name}",
|
||||
);
|
||||
|
||||
// Take the user straight to the login screen for this profile,
|
||||
// rather than leaving them to work out why Home shows disconnected
|
||||
OneContext().push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => InvenTreeLoginWidget(expiredProfile),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
showServerError(
|
||||
_URL_ME,
|
||||
L10().serverNotConnected,
|
||||
L10().serverAuthenticationError,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -557,8 +579,9 @@ class InvenTreeAPI {
|
||||
Future<APIResponse> fetchToken(
|
||||
UserProfile userProfile,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
String password, {
|
||||
bool showDialog = true,
|
||||
}) async {
|
||||
debug("Fetching user token from ${userProfile.server}");
|
||||
|
||||
profile = userProfile;
|
||||
@@ -609,18 +632,22 @@ class InvenTreeAPI {
|
||||
headers: {HttpHeaders.authorizationHeader: authHeader},
|
||||
);
|
||||
|
||||
final data = response.asMap();
|
||||
|
||||
// Invalid response
|
||||
if (!response.successful()) {
|
||||
switch (response.statusCode) {
|
||||
case 401:
|
||||
case 403:
|
||||
showServerError(
|
||||
apiUrl,
|
||||
L10().serverAuthenticationError,
|
||||
L10().invalidUsernamePassword,
|
||||
);
|
||||
default:
|
||||
showStatusCodeError(apiUrl, response.statusCode);
|
||||
if (showDialog) {
|
||||
switch (response.statusCode) {
|
||||
case 401:
|
||||
case 403:
|
||||
showServerError(
|
||||
apiUrl,
|
||||
L10().serverAuthenticationError,
|
||||
L10().invalidUsernamePassword,
|
||||
);
|
||||
default:
|
||||
showStatusCodeError(apiUrl, response.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
debug("Token request failed: STATUS ${response.statusCode}");
|
||||
@@ -628,16 +655,17 @@ class InvenTreeAPI {
|
||||
if (response.data != null) {
|
||||
debug("Response data: ${response.data.toString()}");
|
||||
}
|
||||
}
|
||||
|
||||
final data = response.asMap();
|
||||
|
||||
if (!data.containsKey("token")) {
|
||||
showServerError(
|
||||
apiUrl,
|
||||
L10().tokenMissing,
|
||||
L10().tokenMissingFromResponse,
|
||||
);
|
||||
} else if (!data.containsKey("token")) {
|
||||
// The request was otherwise successful, but the response is missing
|
||||
// the expected token field - a distinct (and much rarer) problem from
|
||||
// an authentication failure, so only reachable when that did NOT occur
|
||||
if (showDialog) {
|
||||
showServerError(
|
||||
apiUrl,
|
||||
L10().tokenMissing,
|
||||
L10().tokenMissingFromResponse,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Save the token to the user profile
|
||||
@@ -945,7 +973,11 @@ class InvenTreeAPI {
|
||||
});
|
||||
} on SocketException catch (error) {
|
||||
debug("SocketException at ${url}: ${error.toString()}");
|
||||
showServerError(url, L10().connectionRefused, error.toString());
|
||||
showServerError(
|
||||
url,
|
||||
L10().connectionRefused,
|
||||
L10().connectionRefusedDetail,
|
||||
);
|
||||
return;
|
||||
} on TimeoutException {
|
||||
debug("TimeoutException at ${url}");
|
||||
@@ -980,8 +1012,12 @@ class InvenTreeAPI {
|
||||
} else {
|
||||
showStatusCodeError(url, response.statusCode);
|
||||
}
|
||||
} on SocketException catch (error) {
|
||||
showServerError(url, L10().connectionRefused, error.toString());
|
||||
} on SocketException {
|
||||
showServerError(
|
||||
url,
|
||||
L10().connectionRefused,
|
||||
L10().connectionRefusedDetail,
|
||||
);
|
||||
} on TimeoutException {
|
||||
showTimeoutError(url);
|
||||
} catch (error, stackTrace) {
|
||||
@@ -1063,7 +1099,11 @@ class InvenTreeAPI {
|
||||
);
|
||||
}
|
||||
} on SocketException catch (error) {
|
||||
showServerError(url, L10().connectionRefused, error.toString());
|
||||
showServerError(
|
||||
url,
|
||||
L10().connectionRefused,
|
||||
L10().connectionRefusedDetail,
|
||||
);
|
||||
response.error = "SocketException";
|
||||
response.errorDetail = error.toString();
|
||||
} on FormatException {
|
||||
@@ -1190,12 +1230,13 @@ class InvenTreeAPI {
|
||||
|
||||
client.badCertificateCallback =
|
||||
(X509Certificate cert, String host, int port) {
|
||||
// The active profile may have been explicitly trusted by the user
|
||||
// (in-flow, after a prior certificate failure) - honor that first
|
||||
if (profile?.trustedCertificate ?? false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_strictHttps) {
|
||||
showServerError(
|
||||
"${host}:${port}",
|
||||
L10().serverCertificateError,
|
||||
L10().serverCertificateInvalid,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1287,10 +1328,10 @@ class InvenTreeAPI {
|
||||
HttpClientRequest? _request;
|
||||
|
||||
// Attempt to open a connection to the server
|
||||
// (retries once after a short delay on a transient network blip -
|
||||
// nothing has been sent yet at this point, so a retry here is safe)
|
||||
try {
|
||||
_request = await httpClient
|
||||
.openUrl(method, _uri)
|
||||
.timeout(Duration(seconds: 10));
|
||||
_request = await _openUrlWithRetry(method, _uri);
|
||||
|
||||
// Default headers
|
||||
defaultHeaders().forEach((key, value) {
|
||||
@@ -1305,7 +1346,11 @@ class InvenTreeAPI {
|
||||
return _request;
|
||||
} on SocketException catch (error) {
|
||||
debug("SocketException at ${url}: ${error.toString()}");
|
||||
showServerError(url, L10().connectionRefused, error.toString());
|
||||
showServerError(
|
||||
url,
|
||||
L10().connectionRefused,
|
||||
L10().connectionRefusedDetail,
|
||||
);
|
||||
return null;
|
||||
} on TimeoutException {
|
||||
debug("TimeoutException at ${url}");
|
||||
@@ -1313,14 +1358,36 @@ class InvenTreeAPI {
|
||||
return null;
|
||||
} on OSError catch (error) {
|
||||
debug("OSError at ${url}: ${error.toString()}");
|
||||
showServerError(url, L10().connectionRefused, error.toString());
|
||||
showServerError(
|
||||
url,
|
||||
L10().connectionRefused,
|
||||
L10().connectionRefusedDetail,
|
||||
);
|
||||
return null;
|
||||
} on CertificateException catch (error) {
|
||||
final HttpClientRequest? retried = await _retryAfterCertificateTrust(
|
||||
url,
|
||||
method,
|
||||
_uri,
|
||||
headers,
|
||||
);
|
||||
if (retried != null) {
|
||||
return retried;
|
||||
}
|
||||
debug("CertificateException at ${url}:");
|
||||
debug(error.toString());
|
||||
showServerError(url, L10().serverCertificateError, error.toString());
|
||||
return null;
|
||||
} on HandshakeException catch (error) {
|
||||
final HttpClientRequest? retried = await _retryAfterCertificateTrust(
|
||||
url,
|
||||
method,
|
||||
_uri,
|
||||
headers,
|
||||
);
|
||||
if (retried != null) {
|
||||
return retried;
|
||||
}
|
||||
debug("HandshakeException at ${url}:");
|
||||
debug(error.toString());
|
||||
showServerError(url, L10().serverCertificateError, error.toString());
|
||||
@@ -1339,6 +1406,107 @@ class InvenTreeAPI {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Open a connection to the given URL, retrying once after a short delay
|
||||
* if the first attempt fails with a transient network error. Nothing has
|
||||
* been sent to the server yet at this point, so a retry here is safe
|
||||
* regardless of HTTP method (unlike retrying after a response has already
|
||||
* started being read/sent).
|
||||
*/
|
||||
Future<HttpClientRequest> _openUrlWithRetry(String method, Uri uri) async {
|
||||
try {
|
||||
return await httpClient
|
||||
.openUrl(method, uri)
|
||||
.timeout(Duration(seconds: 10));
|
||||
} on SocketException {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
return await httpClient
|
||||
.openUrl(method, uri)
|
||||
.timeout(Duration(seconds: 10));
|
||||
} on TimeoutException {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
return await httpClient
|
||||
.openUrl(method, uri)
|
||||
.timeout(Duration(seconds: 10));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* A certificate error occurred while connecting to the active profile's
|
||||
* server. If the user hasn't already trusted this profile's certificate,
|
||||
* prompt them in-flow; if they accept, persist that decision against the
|
||||
* profile and retry the connection once.
|
||||
*/
|
||||
Future<HttpClientRequest?> _retryAfterCertificateTrust(
|
||||
String url,
|
||||
String method,
|
||||
Uri uri,
|
||||
Map<String, String> headers,
|
||||
) async {
|
||||
final UserProfile? prf = profile;
|
||||
|
||||
if (prf == null || prf.trustedCertificate) {
|
||||
// Nothing new to prompt for - surface the original error as-is
|
||||
return null;
|
||||
}
|
||||
|
||||
final bool trust = await _promptTrustCertificate(uri.host);
|
||||
|
||||
if (!trust) {
|
||||
return null;
|
||||
}
|
||||
|
||||
prf.trustedCertificate = true;
|
||||
await UserProfileDBManager().updateProfile(prf);
|
||||
|
||||
try {
|
||||
final HttpClientRequest request = await httpClient
|
||||
.openUrl(method, uri)
|
||||
.timeout(Duration(seconds: 10));
|
||||
|
||||
defaultHeaders().forEach((key, value) {
|
||||
request.headers.set(key, value);
|
||||
});
|
||||
|
||||
headers.forEach((key, value) {
|
||||
request.headers.set(key, value);
|
||||
});
|
||||
|
||||
return request;
|
||||
} catch (error) {
|
||||
debug(
|
||||
"Retry after certificate trust failed at ${url}: ${error.toString()}",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Ask the user whether to trust an otherwise-invalid TLS certificate for
|
||||
* the given host. Returns true if they accept.
|
||||
*/
|
||||
Future<bool> _promptTrustCertificate(String host) async {
|
||||
final Completer<bool> completer = Completer<bool>();
|
||||
|
||||
confirmationDialog(
|
||||
L10().serverCertificateError,
|
||||
"${L10().serverCertificateTrust}\n${host}",
|
||||
icon: TablerIcons.shield_exclamation,
|
||||
onAccept: () {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(true);
|
||||
}
|
||||
},
|
||||
onReject: () {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/*
|
||||
* Complete an API request, and return an APIResponse object
|
||||
*/
|
||||
@@ -1414,7 +1582,11 @@ class InvenTreeAPI {
|
||||
response.error = "HTTPException";
|
||||
response.errorDetail = error.toString();
|
||||
} on SocketException catch (error) {
|
||||
showServerError(url, L10().connectionRefused, error.toString());
|
||||
showServerError(
|
||||
url,
|
||||
L10().connectionRefused,
|
||||
L10().connectionRefusedDetail,
|
||||
);
|
||||
response.error = "SocketException";
|
||||
response.errorDetail = error.toString();
|
||||
} on CertificateException catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user