diff --git a/pkg/freecad/InitGui.py b/pkg/freecad/InitGui.py
index 3a95a64..e63a98b 100644
--- a/pkg/freecad/InitGui.py
+++ b/pkg/freecad/InitGui.py
@@ -39,6 +39,7 @@ class SiloWorkbench(FreeCADGui.Workbench):
"Silo_Info",
"Silo_BOM",
"Silo_Settings",
+ "Silo_Auth",
]
self.appendToolbar("Silo", self.toolbar_commands)
diff --git a/pkg/freecad/resources/icons/silo-auth.svg b/pkg/freecad/resources/icons/silo-auth.svg
new file mode 100644
index 0000000..d05c992
--- /dev/null
+++ b/pkg/freecad/resources/icons/silo-auth.svg
@@ -0,0 +1,8 @@
+
diff --git a/pkg/freecad/silo_commands.py b/pkg/freecad/silo_commands.py
index b63de3b..1d1c7dd 100644
--- a/pkg/freecad/silo_commands.py
+++ b/pkg/freecad/silo_commands.py
@@ -90,6 +90,53 @@ def _get_ssl_context() -> ssl.SSLContext:
return ctx
+def _get_auth_token() -> str:
+ """Get stored auth token from preferences, checking expiry."""
+ param = FreeCAD.ParamGet(_PREF_GROUP)
+ token = param.GetString("AuthToken", "")
+ if not token:
+ return ""
+ expiry = param.GetString("AuthTokenExpiry", "")
+ if expiry:
+ try:
+ from datetime import datetime, timezone
+
+ exp_dt = datetime.fromisoformat(expiry.replace("Z", "+00:00"))
+ if datetime.now(timezone.utc) >= exp_dt:
+ _clear_auth()
+ return ""
+ except (ValueError, TypeError):
+ pass
+ return token
+
+
+def _get_auth_username() -> str:
+ """Get stored authenticated username from preferences."""
+ param = FreeCAD.ParamGet(_PREF_GROUP)
+ return param.GetString("AuthUsername", "")
+
+
+def _get_auth_headers() -> Dict[str, str]:
+ """Return Authorization header dict if authenticated, else empty dict.
+
+ Checks login-based token first, then falls back to static API token.
+ """
+ token = _get_auth_token()
+ if not token:
+ token = _get_api_token()
+ if token:
+ return {"Authorization": f"Bearer {token}"}
+ return {}
+
+
+def _clear_auth():
+ """Clear stored authentication credentials from preferences."""
+ param = FreeCAD.ParamGet(_PREF_GROUP)
+ param.SetString("AuthToken", "")
+ param.SetString("AuthUsername", "")
+ param.SetString("AuthTokenExpiry", "")
+
+
# Category name mapping for folder structure
# Format: CCC -> "descriptive_name"
CATEGORY_NAMES = {
@@ -315,9 +362,7 @@ class SiloClient:
"""Make HTTP request to Silo API."""
url = f"{self.base_url}{path}"
headers = {"Content-Type": "application/json"}
- token = _get_api_token()
- if token:
- headers["Authorization"] = f"Bearer {token}"
+ headers.update(_get_auth_headers())
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, headers=headers, method=method)
@@ -325,6 +370,8 @@ class SiloClient:
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
+ if e.code == 401:
+ _clear_auth()
error_body = e.read().decode()
raise RuntimeError(f"API error {e.code}: {error_body}")
except urllib.error.URLError as e:
@@ -333,10 +380,7 @@ class SiloClient:
def _download_file(self, part_number: str, revision: int, dest_path: str) -> bool:
"""Download a file from MinIO storage."""
url = f"{self.base_url}/items/{part_number}/file/{revision}"
- req = urllib.request.Request(url, method="GET")
- token = _get_api_token()
- if token:
- req.add_header("Authorization", f"Bearer {token}")
+ req = urllib.request.Request(url, headers=_get_auth_headers(), method="GET")
try:
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
@@ -398,9 +442,7 @@ class SiloClient:
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Length": str(len(body)),
}
- token = _get_api_token()
- if token:
- headers["Authorization"] = f"Bearer {token}"
+ headers.update(_get_auth_headers())
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
try:
@@ -579,9 +621,11 @@ class SiloClient:
def delete_bom_entry(self, parent_pn: str, child_pn: str) -> None:
"""Remove a child from a parent's BOM."""
url = f"{self.base_url}/items/{parent_pn}/bom/{child_pn}"
+ headers = {"Content-Type": "application/json"}
+ headers.update(_get_auth_headers())
req = urllib.request.Request(
url,
- headers={"Content-Type": "application/json"},
+ headers=headers,
method="DELETE",
)
try:
@@ -592,6 +636,64 @@ class SiloClient:
except urllib.error.URLError as e:
raise RuntimeError(f"Connection error: {e.reason}")
+ def login(self, username: str, password: str) -> Dict[str, Any]:
+ """Authenticate with the Silo API and store the token."""
+ url = f"{self.base_url}/auth/login"
+ headers = {"Content-Type": "application/json"}
+ body = json.dumps({"username": username, "password": password}).encode()
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")
+
+ try:
+ with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
+ result = json.loads(resp.read().decode())
+ except urllib.error.HTTPError as e:
+ error_body = e.read().decode()
+ try:
+ detail = json.loads(error_body).get("error", error_body)
+ except (json.JSONDecodeError, AttributeError):
+ detail = error_body
+ raise RuntimeError(f"Login failed: {detail}")
+ except urllib.error.URLError as e:
+ raise RuntimeError(f"Connection error: {e.reason}")
+
+ param = FreeCAD.ParamGet(_PREF_GROUP)
+ param.SetString("AuthToken", result.get("token", ""))
+ param.SetString("AuthUsername", result.get("username", username))
+ param.SetString("AuthTokenExpiry", result.get("expires_at", ""))
+ return result
+
+ def logout(self):
+ """Clear stored authentication credentials."""
+ _clear_auth()
+
+ def is_authenticated(self) -> bool:
+ """Return True if a valid auth token is stored."""
+ return bool(_get_auth_token())
+
+ def auth_username(self) -> str:
+ """Return the stored authenticated username."""
+ return _get_auth_username()
+
+ def check_connection(self) -> Tuple[bool, str]:
+ """Check connectivity to the Silo API.
+
+ Returns (reachable, message).
+ """
+ url = f"{self.base_url}/health"
+ req = urllib.request.Request(url, method="GET")
+ try:
+ with urllib.request.urlopen(
+ req, context=_get_ssl_context(), timeout=5
+ ) as resp:
+ return True, f"OK ({resp.status})"
+ except urllib.error.HTTPError as e:
+ # Server responded - reachable even if error
+ return True, f"Server error ({e.code})"
+ except urllib.error.URLError as e:
+ return False, str(e.reason)
+ except Exception as e:
+ return False, str(e)
+
_client = SiloClient()
@@ -2037,12 +2139,55 @@ class Silo_Settings:
layout.addSpacing(10)
+ # Authentication section
+ auth_heading = QtGui.QLabel("Authentication")
+ auth_heading.setTextFormat(QtCore.Qt.RichText)
+ layout.addWidget(auth_heading)
+
+ auth_user = _get_auth_username()
+ auth_status_text = (
+ f"Logged in as {auth_user}"
+ if auth_user and _get_auth_token()
+ else "Not logged in"
+ )
+ auth_status_lbl = QtGui.QLabel(auth_status_text)
+ auth_status_lbl.setTextFormat(QtCore.Qt.RichText)
+ layout.addWidget(auth_status_lbl)
+
+ auth_hint = QtGui.QLabel(
+ "Use the Database Auth panel to log in. "
+ "Credentials are stored locally in FreeCAD preferences."
+ )
+ auth_hint.setWordWrap(True)
+ auth_hint.setStyleSheet("color: #888; font-size: 11px;")
+ layout.addWidget(auth_hint)
+
+ clear_auth_btn = QtGui.QPushButton("Clear Saved Credentials")
+ clear_auth_btn.setEnabled(bool(_get_auth_token()))
+
+ def on_clear_auth():
+ _clear_auth()
+ auth_status_lbl.setText("Not logged in")
+ clear_auth_btn.setEnabled(False)
+ FreeCAD.Console.PrintMessage("Silo: Saved credentials cleared\n")
+
+ clear_auth_btn.clicked.connect(on_clear_auth)
+ layout.addWidget(clear_auth_btn)
+
+ layout.addSpacing(10)
+
# Current effective values (read-only)
cert_display = param.GetString("SslCertPath", "") or "(system defaults)"
+ auth_display = (
+ f"logged in as {auth_user}"
+ if auth_user and _get_auth_token()
+ else "not logged in"
+ )
status_label = QtGui.QLabel(
f"Active URL: {_get_api_url()}
"
f"SSL verification: {'enabled' if _get_ssl_verify() else 'disabled'}
"
- f"CA certificate: {cert_display}"
+ f"CA certificate: {cert_display}
"
+ f"Authentication: {auth_display}"
)
status_label.setTextFormat(QtCore.Qt.RichText)
layout.addWidget(status_label)
@@ -2532,6 +2677,263 @@ class Silo_ToggleMode:
return True
+# ---------------------------------------------------------------------------
+# Auth dock widget
+# ---------------------------------------------------------------------------
+
+
+class SiloAuthDockWidget:
+ """Content widget for the Silo Database Auth dock panel."""
+
+ def __init__(self):
+ from PySide import QtCore, QtGui
+
+ self.widget = QtGui.QWidget()
+ self._build_ui()
+ self._refresh_status()
+
+ self._timer = QtCore.QTimer(self.widget)
+ self._timer.timeout.connect(self._refresh_status)
+ self._timer.start(30000)
+
+ # -- UI construction ----------------------------------------------------
+
+ def _build_ui(self):
+ from PySide import QtCore, QtGui
+
+ layout = QtGui.QVBoxLayout(self.widget)
+ layout.setContentsMargins(8, 8, 8, 8)
+ layout.setSpacing(6)
+
+ # Status row
+ status_row = QtGui.QHBoxLayout()
+ status_row.setSpacing(6)
+ self._status_dot = QtGui.QLabel("\u2b24")
+ self._status_dot.setFixedWidth(16)
+ self._status_dot.setAlignment(QtCore.Qt.AlignCenter)
+ self._status_label = QtGui.QLabel("Checking...")
+ status_row.addWidget(self._status_dot)
+ status_row.addWidget(self._status_label)
+ status_row.addStretch()
+ layout.addLayout(status_row)
+
+ # User row
+ user_row = QtGui.QHBoxLayout()
+ user_row.setSpacing(6)
+ user_lbl = QtGui.QLabel("User:")
+ user_lbl.setStyleSheet("color: #888;")
+ self._user_label = QtGui.QLabel("(not logged in)")
+ user_row.addWidget(user_lbl)
+ user_row.addWidget(self._user_label)
+ user_row.addStretch()
+ layout.addLayout(user_row)
+
+ layout.addSpacing(4)
+
+ # URL row (compact display)
+ url_row = QtGui.QHBoxLayout()
+ url_row.setSpacing(6)
+ url_lbl = QtGui.QLabel("URL:")
+ url_lbl.setStyleSheet("color: #888;")
+ self._url_label = QtGui.QLabel("")
+ self._url_label.setStyleSheet("font-size: 11px;")
+ self._url_label.setWordWrap(True)
+ url_row.addWidget(url_lbl)
+ url_row.addWidget(self._url_label, 1)
+ layout.addLayout(url_row)
+
+ layout.addSpacing(4)
+
+ # Buttons
+ btn_row = QtGui.QHBoxLayout()
+ btn_row.setSpacing(6)
+
+ self._login_btn = QtGui.QPushButton("Login")
+ self._login_btn.clicked.connect(self._on_login_clicked)
+ btn_row.addWidget(self._login_btn)
+
+ settings_btn = QtGui.QToolButton()
+ settings_btn.setText("\u2699")
+ settings_btn.setToolTip("Silo Settings")
+ settings_btn.setFixedSize(28, 28)
+ settings_btn.clicked.connect(self._on_settings_clicked)
+ btn_row.addStretch()
+ btn_row.addWidget(settings_btn)
+
+ layout.addLayout(btn_row)
+ layout.addStretch()
+
+ # -- Status refresh -----------------------------------------------------
+
+ def _refresh_status(self):
+ from PySide import QtGui
+
+ # Update URL display
+ self._url_label.setText(_get_api_url())
+
+ authed = _client.is_authenticated()
+ username = _client.auth_username()
+
+ if authed and username:
+ self._user_label.setText(username)
+ else:
+ self._user_label.setText("(not logged in)")
+
+ # Check server connectivity
+ try:
+ reachable, msg = _client.check_connection()
+ except Exception:
+ reachable = False
+
+ if reachable and authed:
+ self._status_dot.setStyleSheet("color: #4CAF50; font-size: 10px;")
+ self._status_label.setText("Connected")
+ self._login_btn.setText("Logout")
+ try:
+ self._login_btn.clicked.disconnect()
+ except RuntimeError:
+ pass
+ self._login_btn.clicked.connect(self._on_logout_clicked)
+ elif reachable and not authed:
+ self._status_dot.setStyleSheet("color: #FFC107; font-size: 10px;")
+ self._status_label.setText("Connected (no auth)")
+ self._login_btn.setText("Login")
+ try:
+ self._login_btn.clicked.disconnect()
+ except RuntimeError:
+ pass
+ self._login_btn.clicked.connect(self._on_login_clicked)
+ else:
+ self._status_dot.setStyleSheet("color: #F44336; font-size: 10px;")
+ self._status_label.setText("Disconnected")
+ self._login_btn.setText("Login")
+ try:
+ self._login_btn.clicked.disconnect()
+ except RuntimeError:
+ pass
+ self._login_btn.clicked.connect(self._on_login_clicked)
+
+ # -- Actions ------------------------------------------------------------
+
+ def _on_login_clicked(self):
+ self._show_login_dialog()
+
+ def _on_logout_clicked(self):
+ _client.logout()
+ FreeCAD.Console.PrintMessage("Silo: Logged out\n")
+ self._refresh_status()
+
+ def _on_settings_clicked(self):
+ FreeCADGui.runCommand("Silo_Settings")
+ # Refresh after settings may have changed
+ self._refresh_status()
+
+ def _show_login_dialog(self):
+ from PySide import QtCore, QtGui
+
+ dialog = QtGui.QDialog(self.widget)
+ dialog.setWindowTitle("Silo Login")
+ dialog.setMinimumWidth(350)
+
+ layout = QtGui.QVBoxLayout(dialog)
+
+ # Server info
+ server_label = QtGui.QLabel(f"Server: {_get_api_url()}")
+ server_label.setStyleSheet("color: #888; font-size: 11px;")
+ layout.addWidget(server_label)
+
+ layout.addSpacing(8)
+
+ # Username
+ user_label = QtGui.QLabel("Username:")
+ layout.addWidget(user_label)
+ user_input = QtGui.QLineEdit()
+ user_input.setPlaceholderText("LDAP username")
+ # Pre-fill with last known username
+ last_user = _get_auth_username()
+ if last_user:
+ user_input.setText(last_user)
+ layout.addWidget(user_input)
+
+ layout.addSpacing(4)
+
+ # Password
+ pass_label = QtGui.QLabel("Password:")
+ layout.addWidget(pass_label)
+ pass_input = QtGui.QLineEdit()
+ pass_input.setEchoMode(QtGui.QLineEdit.Password)
+ pass_input.setPlaceholderText("LDAP password")
+ layout.addWidget(pass_input)
+
+ layout.addSpacing(4)
+
+ # Error label (hidden initially)
+ error_label = QtGui.QLabel("")
+ error_label.setStyleSheet("color: #F44336;")
+ error_label.setWordWrap(True)
+ error_label.setVisible(False)
+ layout.addWidget(error_label)
+
+ layout.addSpacing(8)
+
+ # Buttons
+ btn_layout = QtGui.QHBoxLayout()
+ login_btn = QtGui.QPushButton("Login")
+ cancel_btn = QtGui.QPushButton("Cancel")
+ btn_layout.addStretch()
+ btn_layout.addWidget(login_btn)
+ btn_layout.addWidget(cancel_btn)
+ layout.addLayout(btn_layout)
+
+ def on_login():
+ username = user_input.text().strip()
+ password = pass_input.text()
+ if not username or not password:
+ error_label.setText("Username and password are required.")
+ error_label.setVisible(True)
+ return
+ try:
+ _client.login(username, password)
+ FreeCAD.Console.PrintMessage(f"Silo: Logged in as {username}\n")
+ dialog.accept()
+ except RuntimeError as e:
+ error_label.setText(str(e))
+ error_label.setVisible(True)
+
+ login_btn.clicked.connect(on_login)
+ cancel_btn.clicked.connect(dialog.reject)
+ pass_input.returnPressed.connect(on_login)
+ user_input.returnPressed.connect(lambda: pass_input.setFocus())
+
+ dialog.exec_()
+ self._refresh_status()
+
+
+class Silo_Auth:
+ """Show the Silo authentication panel."""
+
+ def GetResources(self):
+ return {
+ "MenuText": "Authentication",
+ "ToolTip": "Show Silo authentication status and login",
+ "Pixmap": _icon("auth"),
+ }
+
+ def Activated(self):
+ from PySide import QtGui
+
+ mw = FreeCADGui.getMainWindow()
+ if mw is None:
+ return
+ panel = mw.findChild(QtGui.QDockWidget, "SiloDatabaseAuth")
+ if panel:
+ panel.show()
+ panel.raise_()
+
+ def IsActive(self):
+ return True
+
+
# Register commands
FreeCADGui.addCommand("Silo_Open", Silo_Open())
FreeCADGui.addCommand("Silo_New", Silo_New())
@@ -2546,3 +2948,4 @@ FreeCADGui.addCommand("Silo_Rollback", Silo_Rollback())
FreeCADGui.addCommand("Silo_SetStatus", Silo_SetStatus())
FreeCADGui.addCommand("Silo_Settings", Silo_Settings())
FreeCADGui.addCommand("Silo_ToggleMode", Silo_ToggleMode())
+FreeCADGui.addCommand("Silo_Auth", Silo_Auth())