diff --git a/app/AndroidManifest.xml b/app/AndroidManifest.xml
index 6b112a6da..72790c4fb 100644
--- a/app/AndroidManifest.xml
+++ b/app/AndroidManifest.xml
@@ -196,6 +196,15 @@
android:value=".view.AccountListActivity"/>
+
+
+
+
{
+ Request.Builder builder = chain.request().newBuilder();
if (!TextUtils.isEmpty(access_token)) {
- return chain.proceed(
- chain
- .request()
- .newBuilder()
- .header("Authorization", "Bearer " + access_token)
- .build());
+ builder.header("Authorization", "Bearer " + access_token);
}
- return chain.proceed(chain.request());
+ if (!TextUtils.isEmpty(csrf_token)) {
+ builder.header("X-CSRF-Token", csrf_token);
+ }
+ return chain.proceed(builder.build());
})
.build();
}
@@ -263,17 +375,27 @@ private Status uploadFile(StringWriter writer, String fileBase, String fileExt)
MediaType.parse("application/" + fileExt + "+xml"), writer.toString()))
.build();
+ String uploadUrl = getEndpoint(UPLOAD_URL_PATH);
+
Request request =
new Request.Builder()
- .url(url + UPLOAD_URL_PATH)
- .addHeader("Content-Type", "application/json")
+ .url(uploadUrl)
.method("POST", requestBody)
.build();
- int responseCode;
- Response response = client.newCall(request).execute();
-
- s = response.isSuccessful() ? Status.OK : Status.ERROR;
+ try (Response response = client.newCall(request).execute()) {
+ if (response.isSuccessful()) {
+ s = Status.OK;
+ } else if (response.code() == 401) {
+ // clear token
+ access_token = null;
+ csrf_token = null;
+
+ s = Status.NEED_REFRESH;
+ } else {
+ s = Status.ERROR;
+ }
+ }
} catch (Exception e) {
s = Status.ERROR;
}
@@ -281,6 +403,48 @@ private Status uploadFile(StringWriter writer, String fileBase, String fileExt)
return s;
}
+ @NonNull
+ @Override
+ public Status refreshToken() {
+ if (TextUtils.isEmpty(refresh_token)) {
+ return getNeedAuthStatus();
+ }
+
+ String endpoint = getEndpoint(REFRESH_URL_PATH);
+
+ try {
+ OkHttpClient client = new OkHttpClient();
+ RequestBody body = RequestBody.create(MediaType.parse("application/json"), "{}");
+ Request request = new Request.Builder()
+ .url(endpoint)
+ .addHeader("X-Client-Type", "mobile")
+ .addHeader("Authorization", "Bearer " + refresh_token)
+ .post(body)
+ .build();
+
+ try (Response response = client.newCall(request).execute()) {
+ String responseBody = response.body() != null ? response.body().string() : "";
+
+ if (response.isSuccessful()) {
+ JSONObject obj = new JSONObject(responseBody);
+ return parseAuthData(obj);
+ }
+
+ // clear token if response is not successful
+ access_token = null;
+ csrf_token = null;
+ }
+ } catch (Exception e) {
+ Log.e(getName(), "refreshToken: exception during request", e);
+
+ // clear token
+ access_token = null;
+ csrf_token = null;
+ }
+
+ return getNeedAuthStatus();
+ }
+
@Override
public boolean checkSupport(Feature f) {
switch (f) {
diff --git a/app/src/main/org/runnerup/export/SyncManager.java b/app/src/main/org/runnerup/export/SyncManager.java
index 1c0ae4679..6dfae5c71 100644
--- a/app/src/main/org/runnerup/export/SyncManager.java
+++ b/app/src/main/org/runnerup/export/SyncManager.java
@@ -38,6 +38,7 @@
import android.view.View;
import android.widget.Button;
import android.widget.CheckBox;
+import android.widget.EditText;
import android.widget.TableRow;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
@@ -78,15 +79,18 @@ public class SyncManager {
public static final long ERROR_ACTIVITY_ID = -1L;
// Id to identify a permission request.
private static final int REQUEST_STORAGE = 3003;
+ private static final String TAG = "SyncManager";
private final Map synchronizers = new HashMap<>();
private final LongSparseArray synchronizersById = new LongSparseArray<>();
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final Handler mainHandler = new Handler(Looper.getMainLooper());
PathSimplifier simplifier;
+
private SQLiteDatabase mDB = null;
private AppCompatActivity mActivity = null;
private Context mContext = null;
private ProgressDialog mSpinner = null;
+
private Synchronizer authSynchronizer = null;
private Callback authCallback = null;
private long mID = 0;
@@ -334,10 +338,12 @@ private Status handleRefreshComplete(final Synchronizer synchronizer, final Stat
}
private void handleAuth(Callback callback, final Synchronizer l, AuthMethod authMethod) {
+ Log.d(TAG, "handleAuth: " + l.getName() + ", method=" + authMethod);
authSynchronizer = l;
authCallback = callback;
switch (authMethod) {
case OAUTH2:
+ Log.d(TAG, "handleAuth: starting OAUTH2 activity");
mActivity.startActivityForResult(l.getAuthIntent(mActivity), CONFIGURE_REQUEST);
return;
case USER_PASS:
@@ -347,6 +353,22 @@ private void handleAuth(Callback callback, final Synchronizer l, AuthMethod auth
case FILEPERMISSION:
checkStoragePermissions(mActivity);
askFileUrl(l);
+ return;
+ case MFA:
+ askMfaCode(l);
+ return;
+ case NONE:
+ // Handle unexpected NONE state, likely error or rate limit
+ if (mSpinner != null && mSpinner.isShowing()) {
+ mSpinner.dismiss();
+ }
+ // Maybe show an error dialog?
+ new AlertDialog.Builder(mActivity)
+ .setTitle(getResources().getString(org.runnerup.common.R.string.Error))
+ .setMessage(getResources().getString(org.runnerup.common.R.string.auth_failed_try_again_later))
+ .setPositiveButton(android.R.string.ok, null)
+ .show();
+ return;
}
}
@@ -354,7 +376,8 @@ private void handleAuthComplete(Synchronizer synchronizer, Status s) {
Callback cb = authCallback;
authCallback = null;
authSynchronizer = null;
- if (s == Status.OK) {
+
+ if (s == Status.OK || s == Status.NEED_AUTH) {
ContentValues tmp = new ContentValues();
tmp.put("_id", synchronizer.getId());
tmp.put(DB.ACCOUNT.AUTH_CONFIG, synchronizer.getAuthConfig());
@@ -367,21 +390,71 @@ private void handleAuthComplete(Synchronizer synchronizer, Status s) {
s = Status.ERROR;
}
}
- if (s != Status.OK) {
+
+ if (s != Status.OK && s != Status.NEED_AUTH) {
synchronizer.reset();
}
- cb.run(synchronizer.getName(), s);
+
+ if (s == Status.NEED_AUTH && cb != null) {
+
+ if (mActivity != null) {
+ final Callback originalCallback = cb;
+ final Status authStatus = s;
+ authCallback = originalCallback;
+ authSynchronizer = synchronizer;
+
+ mSpinner.show();
+
+ executor.execute(() -> {
+ mActivity.runOnUiThread(() ->
+ handleAuth(originalCallback, synchronizer, authStatus.authMethod)
+ );
+ });
+
+ return;
+ }
+ }
+
+ if (cb != null) {
+ cb.run(synchronizer.getName(), s);
+ }
}
private JSONObject newObj(String str) {
try {
return new JSONObject(str);
} catch (JSONException e) {
- e.printStackTrace();
+ Log.e(getClass().getName(), "JSON issue", e);
}
return null;
}
+ private void askMfaCode(final Synchronizer sync) {
+ final EditText input = new EditText(mActivity);
+ input.setInputType(InputType.TYPE_CLASS_NUMBER);
+
+ new AlertDialog.Builder(mActivity)
+ .setTitle("MFA Verification")
+ .setMessage("Enter the 6-digit code from your authenticator app")
+ .setView(input)
+ .setPositiveButton(org.runnerup.common.R.string.OK, (dialog, which) -> {
+ String code = input.getText().toString();
+ String authConfigStr = sync.getAuthConfig();
+ JSONObject authConfig = newObj(authConfigStr);
+ if (authConfig == null) authConfig = new JSONObject();
+
+ try {
+ authConfig.put("mfa_code", code);
+ } catch (JSONException e) {
+ Log.e(getClass().getName(), "JSON issue", e);
+ }
+
+ testUserPass(sync, authConfig);
+ })
+ .setNegativeButton(org.runnerup.common.R.string.Cancel, (dialog, which) -> handleAuthComplete(sync, Status.CANCEL))
+ .show();
+ }
+
private void askUsernamePassword(final Synchronizer sync, final AuthMethod authMethod) {
final View view = View.inflate(mActivity, R.layout.userpass, null);
final CheckBox cb = view.findViewById(R.id.showpass);
@@ -439,7 +512,31 @@ private void askUsernamePassword(final Synchronizer sync, final AuthMethod authM
}
testUserPass(sync, authConfig);
})
- .setNeutralButton("Skip", (dialog, which) -> handleAuthComplete(sync, Status.SKIP))
+ .setNeutralButton(
+ (authSynchronizer.getName().equals(EndurainSynchronizer.NAME))
+ ? "Web Login" : "Skip",
+ (dialog, which) ->
+ {
+ if (authSynchronizer.getName().equals(EndurainSynchronizer.NAME)) {
+ try {
+ //noinspection ConstantConditions
+ authConfig.remove("username");
+ authConfig.remove("password");
+ authConfig.remove("access_token");
+ authConfig.remove("refresh_token");
+ authConfig.remove("csrf_token");
+
+ if (authMethod == AuthMethod.USER_PASS_URL) {
+ authConfig.put(DB.ACCOUNT.URL, urlInput.getText());
+ }
+ } catch (JSONException e) {
+ e.printStackTrace();
+ }
+ testUserPass(sync, authConfig);
+ } else {
+ handleAuthComplete(sync, Status.SKIP);
+ }
+ })
.setNegativeButton(
org.runnerup.common.R.string.Cancel,
(dialog, which) -> handleAuthComplete(sync, Status.SKIP))
diff --git a/app/src/main/org/runnerup/export/Synchronizer.java b/app/src/main/org/runnerup/export/Synchronizer.java
index cf54fc696..7eeb2ff33 100644
--- a/app/src/main/org/runnerup/export/Synchronizer.java
+++ b/app/src/main/org/runnerup/export/Synchronizer.java
@@ -45,7 +45,8 @@ enum AuthMethod {
OAUTH2,
USER_PASS,
FILEPERMISSION,
- USER_PASS_URL
+ USER_PASS_URL,
+ MFA
}
enum Status {
diff --git a/app/src/main/org/runnerup/export/util/PKCEUtil.java b/app/src/main/org/runnerup/export/util/PKCEUtil.java
new file mode 100644
index 000000000..aee34f6a8
--- /dev/null
+++ b/app/src/main/org/runnerup/export/util/PKCEUtil.java
@@ -0,0 +1,25 @@
+package org.runnerup.export.util;
+
+import android.util.Base64;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.io.UnsupportedEncodingException;
+import java.security.NoSuchAlgorithmException;
+
+public class PKCEUtil {
+
+ public static String generateCodeVerifier() {
+ SecureRandom sr = new SecureRandom();
+ byte[] code = new byte[64];
+ sr.nextBytes(code);
+ return Base64.encodeToString(code, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
+ }
+
+ public static String generateCodeChallenge(String verifier) throws NoSuchAlgorithmException, UnsupportedEncodingException {
+ byte[] bytes = verifier.getBytes("US-ASCII");
+ MessageDigest md = MessageDigest.getInstance("SHA-256");
+ md.update(bytes, 0, bytes.length);
+ byte[] digest = md.digest();
+ return Base64.encodeToString(digest, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
+ }
+}
diff --git a/app/src/main/org/runnerup/view/EndurainLoginActivity.java b/app/src/main/org/runnerup/view/EndurainLoginActivity.java
new file mode 100644
index 000000000..ed5e15b7b
--- /dev/null
+++ b/app/src/main/org/runnerup/view/EndurainLoginActivity.java
@@ -0,0 +1,298 @@
+package org.runnerup.view;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Intent;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+import java.io.IOException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.runnerup.export.util.PKCEUtil;
+
+public class EndurainLoginActivity extends AppCompatActivity {
+
+ public static final String EXTRA_URL = "url";
+ public static final String EXTRA_ACCESS_TOKEN = "access_token";
+ public static final String EXTRA_REFRESH_TOKEN = "refresh_token";
+ public static final String EXTRA_CSRF_TOKEN = "csrf_token";
+ private static final String KEY_VERIFIER = "pkce_verifier";
+
+ private String verifier;
+ private String instanceUrl;
+ private WebView webView;
+ private final ExecutorService executor = Executors.newSingleThreadExecutor();
+ private boolean isFinishing = false;
+
+ @SuppressLint("SetJavaScriptEnabled")
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ webView = new WebView(this);
+ setContentView(webView);
+
+ instanceUrl = getIntent().getStringExtra(EXTRA_URL);
+ if (instanceUrl == null) {
+ finish();
+ return;
+ }
+
+ if (instanceUrl.endsWith("/")) {
+ instanceUrl = instanceUrl.substring(0, instanceUrl.length() - 1);
+ }
+ if (instanceUrl.endsWith("/login")) {
+ instanceUrl = instanceUrl.substring(0, instanceUrl.length() - 6);
+ }
+
+ webView.getSettings().setJavaScriptEnabled(true);
+ webView.getSettings().setDomStorageEnabled(true);
+
+ if (savedInstanceState != null) {
+ verifier = savedInstanceState.getString(KEY_VERIFIER);
+ webView.restoreState(savedInstanceState);
+ }
+
+ if (verifier == null) {
+ verifier = PKCEUtil.generateCodeVerifier();
+ }
+
+ if (savedInstanceState == null) {
+ fetchIdpsAndLoad();
+ } else {
+ setupWebViewClient();
+ }
+ }
+
+ @Override
+ protected void onSaveInstanceState(@NonNull Bundle outState) {
+ super.onSaveInstanceState(outState);
+ outState.putString(KEY_VERIFIER, verifier);
+ webView.saveState(outState);
+ }
+
+ private void fetchIdpsAndLoad() {
+ if (executor.isShutdown()) return;
+ executor.execute(() -> {
+ runOnUiThread(() -> {
+ setupWebViewClient();
+ loadWebView(instanceUrl + "/login");
+ });
+ });
+ }
+
+ private void setupWebViewClient() {
+ try {
+ final String challenge = PKCEUtil.generateCodeChallenge(verifier);
+
+ webView.setWebViewClient(new WebViewClient() {
+ @Override
+ public void onPageStarted(WebView view, String url, Bitmap favicon) {
+ checkUrlAndExtractTokens(view, url, challenge);
+ }
+
+ @Override
+ public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) {
+ checkUrlAndExtractTokens(view, url, challenge);
+ super.doUpdateVisitedHistory(view, url, isReload);
+ }
+
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
+ String url = request.getUrl().toString();
+ return handleOverride(view, url, challenge);
+ }
+
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, String url) {
+ return handleOverride(view, url, challenge);
+ }
+ });
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ private boolean handleOverride(WebView view, String url, String challenge) {
+ if (isFinishing) return true;
+
+ if (url.contains("sso=success") && url.contains("session_id=")) {
+ Uri uri = Uri.parse(url);
+ String sessionId = uri.getQueryParameter("session_id");
+
+ if (sessionId == null || sessionId.trim().isEmpty()) {
+ Log.i("EndurainLoginActivity", "SSO redirect missing valid session_id");
+ setResult(Activity.RESULT_CANCELED);
+ finish();
+ return true;
+ }
+
+ exchangeSessionForTokens(sessionId);
+ return true;
+ }
+
+ if (url.contains("/api/v1/public/idp/login/")) {
+ if (!url.contains("code_challenge=" + challenge)) {
+
+ Uri baseUri = Uri.parse(url);
+ Uri.Builder newUri = baseUri.buildUpon();
+
+ newUri.clearQuery();
+
+ newUri.appendQueryParameter("code_challenge", challenge);
+ newUri.appendQueryParameter("code_challenge_method", "S256");
+
+ view.loadUrl(newUri.build().toString());
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void checkUrlAndExtractTokens(WebView view, String url, String challenge) {
+ if (isFinishing) return;
+
+ if (url.contains("sso=success") && url.contains("session_id=")) {
+ view.stopLoading();
+ Uri uri = Uri.parse(url);
+ String sessionId = uri.getQueryParameter("session_id");
+ exchangeSessionForTokens(sessionId);
+ return;
+ }
+
+ if (url.contains("/api/v1/public/idp/login/")) {
+ if (!url.contains("code_challenge=" + challenge)) {
+ Log.i("EndurainLoginActivity", "Intercepted SSO login load, reloading with Android PKCE");
+ view.stopLoading();
+
+ Uri baseUri = Uri.parse(url);
+ Uri.Builder newUri = baseUri.buildUpon();
+ newUri.clearQuery();
+ newUri.appendQueryParameter("code_challenge", challenge);
+ newUri.appendQueryParameter("code_challenge_method", "S256");
+
+ view.loadUrl(newUri.build().toString());
+ return;
+ }
+ }
+
+ if (url.equals(instanceUrl + "/") || url.equals(instanceUrl) || (url.startsWith(instanceUrl) && !url.contains("/login") && !url.contains("/api/"))) {
+ Log.i("EndurainLogin", "Detected authenticated page load: " + url);
+
+ view.evaluateJavascript(
+ "(function() { return localStorage.getItem('access_token'); })();",
+ value -> {
+ if (value != null && !value.equals("null")) {
+ String token = value.replaceAll("^\"|\"$", "");
+ view.evaluateJavascript(
+ "(function() { return localStorage.getItem('refresh_token'); })();",
+ refreshValue -> {
+ String refreshToken = null;
+ if (refreshValue != null && !refreshValue.equals("null")) {
+ refreshToken = refreshValue.replaceAll("^\"|\"$", "");
+ }
+ finishWithSuccess(token, refreshToken, null);
+ }
+ );
+ }
+ }
+ );
+ }
+ }
+
+ private void loadWebView(String url) {
+ webView.loadUrl(url);
+ }
+
+ private void finishWithSuccess(String accessToken, String refreshToken, String csrfToken) {
+ if (isFinishing) return;
+ isFinishing = true;
+
+ Intent result = new Intent();
+ if (accessToken != null) result.putExtra(EXTRA_ACCESS_TOKEN, accessToken);
+ if (refreshToken != null) result.putExtra(EXTRA_REFRESH_TOKEN, refreshToken);
+ if (csrfToken != null) result.putExtra(EXTRA_CSRF_TOKEN, csrfToken);
+
+ setResult(Activity.RESULT_OK, result);
+ finish();
+ }
+
+ @Override
+ protected void onDestroy() {
+ isFinishing = true;
+
+ if (webView != null) {
+ webView.stopLoading();
+ webView.setWebViewClient(null);
+ webView.destroy();
+ webView = null;
+ }
+
+ executor.shutdown();
+ super.onDestroy();
+ }
+
+ private void exchangeSessionForTokens(String sessionId) {
+ if (executor.isShutdown()) return;
+ executor.execute(() -> {
+ boolean success = false;
+ OkHttpClient client = new OkHttpClient();
+ MediaType JSON = MediaType.parse("application/json; charset=utf-8");
+ JSONObject jsonBody = new JSONObject();
+ try {
+ jsonBody.put("code_verifier", verifier);
+ } catch (JSONException e) {
+ Log.i("EndurainLoginActivity", "JSON issue", e);
+ }
+
+ RequestBody body = RequestBody.create(JSON, jsonBody.toString());
+ Request request = new Request.Builder()
+ .url(instanceUrl + "/api/v1/public/idp/session/" + sessionId + "/tokens")
+ .addHeader("X-Client-Type", "mobile")
+ .post(body)
+ .build();
+
+ try (Response response = client.newCall(request).execute()) {
+ if (response.isSuccessful() && response.body() != null) {
+ String responseString = response.body().string();
+ JSONObject jsonResponse = new JSONObject(responseString);
+
+ String accessToken = jsonResponse.optString("access_token", null);
+ String refreshToken = jsonResponse.optString("refresh_token", null);
+ String csrfToken = jsonResponse.optString("csrf_token", null);
+
+ runOnUiThread(() -> finishWithSuccess(accessToken, refreshToken, csrfToken));
+ success = true;
+ } else {
+ String errorBody = response.body() != null ? response.body().string() : "";
+ Log.i("EndurainLoginActivity", "Token exchange failed: " + response.code() + " " + response.message() + " Body: " + errorBody);
+ }
+ } catch (IOException | JSONException e) {
+ Log.i("EndurainLoginActivity", "Token exchange failed", e);
+ }
+
+ if (!success) {
+ runOnUiThread(() -> {
+ if (!isFinishing) {
+ setResult(Activity.RESULT_CANCELED);
+ finish();
+ }
+ });
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/common/src/main/res/values-de/strings.xml b/common/src/main/res/values-de/strings.xml
index 2e27ffc68..860f5b1a1 100644
--- a/common/src/main/res/values-de/strings.xml
+++ b/common/src/main/res/values-de/strings.xml
@@ -367,4 +367,7 @@ Für die Wartung der Daten kann die Datei runnerup.db.export von/nach %1$s im-/e
Laufen
Radfahren
Andere
+ Authentifizierung fehlgeschlagen oder Rate-Limit überschritten. Bitte versuchen Sie es später noch einmal.
+
+
diff --git a/common/src/main/res/values-fr/strings.xml b/common/src/main/res/values-fr/strings.xml
index 46921b6ca..ffa1839e5 100644
--- a/common/src/main/res/values-fr/strings.xml
+++ b/common/src/main/res/values-fr/strings.xml
@@ -359,4 +359,5 @@
Course
Vélo
Autre
+ Échec de l\'authentification ou limite de débit dépassée. Veuillez réessayer plus tard.
diff --git a/common/src/main/res/values-it/strings.xml b/common/src/main/res/values-it/strings.xml
index 24bea05cb..8c30064bb 100644
--- a/common/src/main/res/values-it/strings.xml
+++ b/common/src/main/res/values-it/strings.xml
@@ -355,4 +355,5 @@
Corsa
Bici
Altro
+ Autenticazione fallita o limite di frequenza superato. Si prega di riprovare più tardi.
diff --git a/common/src/main/res/values-pt/strings.xml b/common/src/main/res/values-pt/strings.xml
index 3b3199471..8415c655a 100644
--- a/common/src/main/res/values-pt/strings.xml
+++ b/common/src/main/res/values-pt/strings.xml
@@ -353,4 +353,5 @@
Registar temperatura
Obter a velocidade a partir do sensor GPS em vez de a calcular a partir das posições GPS
Definir o período de cálculo da média móvel para informar sobre o objetivo e o período de carência para os avisos de áudio
+ Falha na autenticação ou limite de taxa excedido. Por favor, tente novamente mais tarde.
diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml
index a037ce2d5..e16d506c7 100644
--- a/common/src/main/res/values/strings.xml
+++ b/common/src/main/res/values/strings.xml
@@ -380,4 +380,6 @@
Import cancelled
Export cancelled
Selected file is not a valid RunnerUp database!
+
+ Authentication failed or rate limit exceeded. Please try again later.