diff --git a/physiolabxr/__init__.py b/physiolabxr/__init__.py index ffdb3d4f..88c75781 100644 --- a/physiolabxr/__init__.py +++ b/physiolabxr/__init__.py @@ -1,17 +1,21 @@ +import os def physiolabxr(): import multiprocessing import sys + import webbrowser from PyQt6 import QtWidgets from PyQt6.QtGui import QIcon - from PyQt6.QtWidgets import QSystemTrayIcon, QMenu + from PyQt6.QtWidgets import QSystemTrayIcon, QMenu, QInputDialog, QMessageBox + from PyQt6.QtCore import QSettings from physiolabxr.configs.configs import AppConfigs from physiolabxr.configs.NetworkManager import NetworkManager from physiolabxr.ui.SplashScreen import SplashScreen - + from physiolabxr.ui.SplashScreen import SplashLoadingTextNotifier + from physiolabxr.ui.Login import LoginDialog AppConfigs(_reset=False) # create the singleton app configs object NetworkManager() @@ -30,6 +34,18 @@ def physiolabxr(): splash = SplashScreen() splash.show() + SplashLoadingTextNotifier().set_loading_text("Logging in...") + + login_dialog = LoginDialog() + + auto_login_success = login_dialog.auto_login() + + if not auto_login_success: + if login_dialog.exec() != QtWidgets.QDialog.DialogCode.Accepted: + splash.close() + QMessageBox.critical(None, "Access Denied", "Login required to access the application.") + sys.exit() + # load default settings from physiolabxr.utils.setup_utils import run_setup_check run_setup_check() @@ -56,4 +72,4 @@ def physiolabxr(): sys.exit(app.exec()) except KeyboardInterrupt: print('App terminate by KeyboardInterrupt') - sys.exit() + sys.exit() \ No newline at end of file diff --git a/physiolabxr/_ui/login.ui b/physiolabxr/_ui/login.ui new file mode 100644 index 00000000..6121fa23 --- /dev/null +++ b/physiolabxr/_ui/login.ui @@ -0,0 +1,130 @@ + + + Dialog + + + + 0 + 0 + 425 + 358 + + + + Dialog + + + + + 130 + 10 + 191 + 20 + + + + Welcome to PhysioLabXR! + + + + + + 40 + 150 + 331 + 41 + + + + Log In + + + + + + 40 + 270 + 331 + 41 + + + + Sign Up + + + + + + 40 + 70 + 331 + 21 + + + + + + + 40 + 50 + 58 + 16 + + + + Email: + + + + + + 40 + 100 + 58 + 16 + + + + Password: + + + + + + 40 + 120 + 331 + 21 + + + + + + + 40 + 240 + 251 + 16 + + + + Doesn't have an account with us yet? + + + + + + 80 + 200 + 251 + 20 + + + + Remember my account on this device + + + + + + diff --git a/physiolabxr/_ui/mainwindow.ui b/physiolabxr/_ui/mainwindow.ui index 1e2e0136..39f61305 100644 --- a/physiolabxr/_ui/mainwindow.ui +++ b/physiolabxr/_ui/mainwindow.ui @@ -62,8 +62,8 @@ 0 0 - 1554 - 857 + 1524 + 810 @@ -293,7 +293,7 @@ 0 0 1600 - 21 + 37 @@ -303,6 +303,7 @@ + @@ -312,8 +313,15 @@ + + + Account + + + + @@ -346,6 +354,16 @@ Settings + + + Sign out + + + + + Sign Out + + tabWidget diff --git a/physiolabxr/configs/configs.py b/physiolabxr/configs/configs.py index c75c89ef..805deec1 100644 --- a/physiolabxr/configs/configs.py +++ b/physiolabxr/configs/configs.py @@ -79,7 +79,7 @@ class AppConfigs(metaclass=Singleton): _app_data_name: str = 'RenaLabApp' app_data_path = os.path.join(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation), _app_data_name) - + print("⚠️" + app_data_path) # appearance configs theme: str = 'dark' # TODO: refactor this to an enum, replace config.value @@ -130,6 +130,10 @@ class AppConfigs(metaclass=Singleton): # Tobii Pro Fusion Eye Tracker Manager tobii_app_path: str = None + # user login + remembered_token: str = None + refresh_token: str = None + _media_paths = ['physiolabxr/_media/icons', 'physiolabxr/_media/logo', 'physiolabxr/_media/gifs'] _supported_media_formats = ['.svg', '.gif'] _ui_path = 'physiolabxr/_ui' diff --git a/physiolabxr/ui/Login.py b/physiolabxr/ui/Login.py new file mode 100644 index 00000000..3368b829 --- /dev/null +++ b/physiolabxr/ui/Login.py @@ -0,0 +1,193 @@ +import os +import sys +import webbrowser +import requests +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont +from dotenv import load_dotenv +import firebase_admin +from firebase_admin import auth +from PyQt6 import QtCore, QtWidgets +from PyQt6.QtWidgets import QDialog, QMessageBox +from physiolabxr.configs.configs import AppConfigs + + +FIREBASE_API_KEY = "AIzaSyD7CJXqoCPtv2GzMQpGLKwTo4MacPqjqnw" + +class LoginDialog(QDialog): + def __init__(self): + super().__init__() + self.setupUi() + + + def setupUi(self): + self.setObjectName("Dialog") + self.resize(425, 358) + + self.label = QtWidgets.QLabel(self) + self.label.setGeometry(QtCore.QRect(50, 10, 191, 20)) + self.label.setObjectName("label") + + self.pushButton = QtWidgets.QPushButton(self) + self.pushButton.setGeometry(QtCore.QRect(40, 150, 331, 41)) + self.pushButton.setObjectName("pushButton") + + self.pushButton_2 = QtWidgets.QPushButton(self) + self.pushButton_2.setGeometry(QtCore.QRect(40, 270, 331, 41)) + self.pushButton_2.setObjectName("pushButton_2") + + self.lineEdit = QtWidgets.QLineEdit(self) + self.lineEdit.setGeometry(QtCore.QRect(40, 70, 331, 21)) + self.lineEdit.setObjectName("lineEdit") + + self.label_2 = QtWidgets.QLabel(self) + self.label_2.setGeometry(QtCore.QRect(40, 50, 58, 16)) + self.label_2.setObjectName("label_2") + + self.label_3 = QtWidgets.QLabel(self) + self.label_3.setGeometry(QtCore.QRect(40, 100, 58, 16)) + self.label_3.setObjectName("label_3") + + self.lineEdit_2 = QtWidgets.QLineEdit(self) + self.lineEdit_2.setGeometry(QtCore.QRect(40, 120, 331, 21)) + self.lineEdit_2.setObjectName("lineEdit_2") + self.lineEdit_2.setEchoMode(QtWidgets.QLineEdit.EchoMode.Password) # Hide password input + + self.label_4 = QtWidgets.QLabel(self) + self.label_4.setGeometry(QtCore.QRect(40, 240, 251, 16)) + self.label_4.setObjectName("label_4") + + self.checkBox = QtWidgets.QCheckBox(self) + self.checkBox.setGeometry(QtCore.QRect(80, 200, 251, 20)) + self.checkBox.setObjectName("checkBox") + + self.retranslateUi() + self.pushButton.clicked.connect(self.handle_login) + self.pushButton_2.clicked.connect(self.handle_signup) + + def retranslateUi(self): + self.setWindowTitle("Welcome") + self.label.setText("Log into your PhysioLabXR account") + font = QFont() + font.setBold(True) # Make the text bold + font.setPointSize(20) # Set the font size + self.label.setFont(font) + self.label.adjustSize() + + self.pushButton.setText("Log In") + self.pushButton_2.setText("Sign Up") + self.label_2.setText("Email:") + self.label_3.setText("Password:") + self.label_4.setText("Don't have an account yet?") + self.checkBox.setText("Remember my account on this device") + + def handle_login(self): + """Handle user login using Firebase Authentication & Store Refresh Token.""" + email = self.lineEdit.text() + password = self.lineEdit_2.text() + + if not email or not password: + QMessageBox.warning(self, "Error", "Please enter both email and password.") + return + + try: + url = f"https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key={FIREBASE_API_KEY}" + data = {"email": email, "password": password, "returnSecureToken": True} + response = requests.post(url, json=data) + response_data = response.json() + + if "idToken" in response_data: + id_token = response_data["idToken"] + refresh_token = response_data["refreshToken"] + local_id = response_data["localId"] + + if self.checkBox.isChecked(): + AppConfigs().remembered_token = id_token + AppConfigs().refresh_token = refresh_token + + print(f"✅ Login Successful - User: {local_id}") + + self.accept() # Close the dialog on success + + else: + error_message = response_data.get("error", {}).get("message", "Unknown error occurred.") + QMessageBox.critical(self, "Login Failed", f"An error occurred: {error_message}") + + except Exception as e: + QMessageBox.critical(self, "Error", f"An unexpected error occurred: {e}") + + def refresh_id_token(self): + """Refresh Firebase ID Token using the refresh token.""" + refresh_token = AppConfigs().refresh_token + + if not refresh_token: + print("❌ No refresh token found, user needs to log in again.") + return None + + try: + url = f"https://securetoken.googleapis.com/v1/token?key={FIREBASE_API_KEY}" + data = {"grant_type": "refresh_token", "refresh_token": refresh_token} + response = requests.post(url, json=data) + response_data = response.json() + + if "id_token" in response_data: + new_id_token = response_data["id_token"] + new_refresh_token = response_data["refresh_token"] + + # ✅ Update stored tokens + AppConfigs().remembered_token = new_id_token + AppConfigs().refresh_token = new_refresh_token + + print("🔄 Token refreshed successfully") + return new_id_token + + else: + print("❌ Failed to refresh token") + return None + + except Exception as e: + QMessageBox.critical(self, "Token Refresh Failed", f"An unexpected error occurred: {e}") + return None + + def auto_login(self): + """Auto-login using stored and refreshed token.""" + id_token = self.refresh_id_token() or AppConfigs().remembered_token + + if not id_token: + print("❌ No valid token found, requiring manual login.") + return False + + try: + url = f"https://identitytoolkit.googleapis.com/v1/accounts:lookup?key={FIREBASE_API_KEY}" + data = {"idToken": id_token} + response = requests.post(url, json=data) + + if response.status_code == 200: + print("✅ Auto-login successful") + self.accept() + return True + else: + print("❌ Invalid token, requiring manual login.") + AppConfigs().remembered_token = None + return False + + except Exception as e: + QMessageBox.critical(self, "Auto Login Failed", f"An unexpected error occurred: {e}") + return False + + def handle_signup(self): + """Redirect user to the signup webpage.""" + signup_url = "https://storage.googleapis.com/physiolabxr.org/signup.html" + webbrowser.open(signup_url) + QMessageBox.information(self, "Redirecting", "You will be redirected to the signup page.") + +if __name__ == "__main__": + # Initialize Firebase Admin SDK if not already initialized + if not firebase_admin._apps: + cred = firebase_admin.credentials.Certificate( + "path/to/serviceAccountKey.json") # Replace with your Google credentials path + firebase_admin.initialize_app(cred) + + app = QtWidgets.QApplication(sys.argv) + dialog = LoginDialog() + dialog.exec() diff --git a/physiolabxr/ui/MainWindow.py b/physiolabxr/ui/MainWindow.py index 16adfa67..82c49a97 100644 --- a/physiolabxr/ui/MainWindow.py +++ b/physiolabxr/ui/MainWindow.py @@ -44,6 +44,8 @@ import numpy as np +from firebase_admin import auth + # Define function to import external files when using PyInstaller. def resource_path(relative_path): @@ -127,6 +129,7 @@ def __init__(self, app, ask_to_close=True, *args, **kwargs): self.actionShow_Recordings.triggered.connect(self.fire_action_show_recordings) self.actionExit.triggered.connect(self.fire_action_exit) self.actionSettings.triggered.connect(self.fire_action_settings) + self.actionSign_Out.triggered.connect(self.on_sign_out_triggered) # create the settings window self.settings_widget = SettingsWidget(self) @@ -518,4 +521,36 @@ def resizeEvent(self, a0): self.adjust_notification_panel_location() def adjust_notification_panel_location(self): - self.notification_panel.move(self.width() - self.notification_panel.width() - 9, self.height() - self.notification_panel.height() - self.recording_file_size_label.height() - 12) # substract 64 to account for margin \ No newline at end of file + self.notification_panel.move(self.width() - self.notification_panel.width() - 9, self.height() - self.notification_panel.height() - self.recording_file_size_label.height() - 12) # substract 64 to account for margin + + def on_sign_out_triggered(self): + """Handles user sign-out, clears remembered user data, and quits the app.""" + reply = QtWidgets.QMessageBox.question( + self, + 'Sign Out', + 'Signing out will close the app and remove your saved account login information. Are you sure?', + QtWidgets.QMessageBox.StandardButton.Yes | QtWidgets.QMessageBox.StandardButton.No, + QtWidgets.QMessageBox.StandardButton.No + ) + + if reply == QtWidgets.QMessageBox.StandardButton.Yes: + try: + # Get the current remembered user UID + remembered_uid = AppConfigs().remembered_uid + + if remembered_uid: + # Revoke Firebase session (Optional: Only if using Firebase Admin) + auth.revoke_refresh_tokens(remembered_uid) + + # Clear remembered user data + AppConfigs().remembered_token = None + AppConfigs().refresh_token = None + + # Display message + QMessageBox.information(self, "Signed Out", "You have been signed out.") + + # Close the app + self.ask_to_close = False + self.close() + except Exception as e: + QMessageBox.critical(self, "Sign Out Failed", f"Error while signing out: {e}") diff --git a/requirements.dev.txt b/requirements.dev.txt index 2678e44f..11c9a738 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -22,3 +22,5 @@ imblearn toml grpcio grpcio-tools +python-dotenv +firebase-admin \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 010c90b3..c5bbc046 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,10 +14,11 @@ setuptools psutil numba PyOpenGL -PyOpenGL_accelerate soundfile matplotlib imblearn toml grpcio -grpcio-tools \ No newline at end of file +grpcio-tools +python-dotenv +firebase-admin \ No newline at end of file