Align client auth with backend: API tokens, session login, /api/auth/me
Rework the FreeCAD client authentication to match the Silo backend's
actual auth implementation (local, LDAP, OIDC/Keycloak, API tokens).
Auth model change:
- Remove the old AuthToken/AuthTokenExpiry session-token approach
- Unify on API tokens (ApiToken preference / SILO_API_TOKEN env var)
as the single auth mechanism for the desktop client
- _get_auth_token() now delegates to _get_api_token() directly
- All requests use Bearer token auth via _get_auth_headers()
SiloClient.login() rewrite:
- POST form-encoded credentials to /login (matching the backend's
form-based login handler, works with local and LDAP backends)
- Use the resulting session cookie to call GET /api/auth/me to get
user info (username, role, auth_source)
- Create a persistent API token via POST /api/auth/tokens named
'FreeCAD (hostname)' with 90-day expiry
- Store the raw token in ApiToken preference for all future requests
- No more ephemeral session tokens — API tokens survive restarts
New SiloClient methods:
- get_current_user(): GET /api/auth/me, returns user dict or None
- refresh_auth_info(): fetches /api/auth/me and updates cached prefs
- auth_role(): returns stored role (admin/editor/viewer)
- auth_source(): returns stored auth source (local/ldap/oidc)
- list_tokens(): GET /api/auth/tokens
- create_token(name, expires_in_days): POST /api/auth/tokens
- revoke_token(token_id): DELETE /api/auth/tokens/{id}
New preference keys:
- AuthRole: cached user role from server
- AuthSource: cached auth source (local, ldap, oidc)
Removed preference keys:
- AuthToken: replaced by ApiToken (was duplicative)
- AuthTokenExpiry: API tokens have server-side expiry
Auth helper changes:
- _save_auth_info(): stores username, role, source, and optionally token
- _clear_auth(): clears ApiToken, AuthUsername, AuthRole, AuthSource
- _get_auth_role(), _get_auth_source(): new accessors
Dock widget updates:
- New Role row showing role and auth source (e.g. 'editor (ldap)')
- Status refresh validates token against /api/auth/me on each poll
- Four status states: Connected (green), Token invalid (orange),
Connected no auth (yellow), Disconnected (red)
- Caches validated user info back to preferences
Login dialog updates:
- Info text explains the flow (creates persistent API token)
- Placeholder text updated (removed LDAP-specific wording)
- Shows 'Logging in...' status during auth
- Displays role and auth source on success
- Disables login button during request
Settings dialog updates:
- API Token input field with show/hide toggle
- Token can be pasted directly from Silo web UI
- Hint text explains token sources (web UI, Login, env var)
- 'Clear Token and Logout' button replaces old clear credentials
- Save handler persists token changes
- Status summary shows role and auth source
check_connection() fix:
- Uses origin /health (not /api/health) matching actual route
Fixes the endpoint mismatch where client was calling a non-existent
POST /api/auth/login JSON endpoint. Now uses the actual form-based
POST /login + session cookie flow that the backend implements.
This commit is contained in:
@@ -91,23 +91,11 @@ def _get_ssl_context() -> ssl.SSLContext:
|
||||
|
||||
|
||||
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
|
||||
"""Get the active API token for authenticating requests.
|
||||
|
||||
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
|
||||
Priority: ApiToken preference > SILO_API_TOKEN env var.
|
||||
"""
|
||||
return _get_api_token()
|
||||
|
||||
|
||||
def _get_auth_username() -> str:
|
||||
@@ -116,25 +104,43 @@ def _get_auth_username() -> str:
|
||||
return param.GetString("AuthUsername", "")
|
||||
|
||||
|
||||
def _get_auth_headers() -> Dict[str, str]:
|
||||
"""Return Authorization header dict if authenticated, else empty dict.
|
||||
def _get_auth_role() -> str:
|
||||
"""Get stored authenticated user role from preferences."""
|
||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||
return param.GetString("AuthRole", "")
|
||||
|
||||
Checks login-based token first, then falls back to static API token.
|
||||
"""
|
||||
|
||||
def _get_auth_source() -> str:
|
||||
"""Get stored authentication source from preferences."""
|
||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||
return param.GetString("AuthSource", "")
|
||||
|
||||
|
||||
def _get_auth_headers() -> Dict[str, str]:
|
||||
"""Return Authorization header dict if a token is configured, else empty."""
|
||||
token = _get_auth_token()
|
||||
if not token:
|
||||
token = _get_api_token()
|
||||
if token:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
return {}
|
||||
|
||||
|
||||
def _save_auth_info(username: str, role: str = "", source: str = "", token: str = ""):
|
||||
"""Store authentication info in preferences."""
|
||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||
param.SetString("AuthUsername", username)
|
||||
param.SetString("AuthRole", role)
|
||||
param.SetString("AuthSource", source)
|
||||
if token:
|
||||
param.SetString("ApiToken", token)
|
||||
|
||||
|
||||
def _clear_auth():
|
||||
"""Clear stored authentication credentials from preferences."""
|
||||
param = FreeCAD.ParamGet(_PREF_GROUP)
|
||||
param.SetString("AuthToken", "")
|
||||
param.SetString("ApiToken", "")
|
||||
param.SetString("AuthUsername", "")
|
||||
param.SetString("AuthTokenExpiry", "")
|
||||
param.SetString("AuthRole", "")
|
||||
param.SetString("AuthSource", "")
|
||||
|
||||
|
||||
# Category name mapping for folder structure
|
||||
@@ -636,50 +642,190 @@ 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")
|
||||
# -- Authentication methods ---------------------------------------------
|
||||
|
||||
def login(self, username: str, password: str) -> Dict[str, Any]:
|
||||
"""Authenticate with credentials and obtain an API token.
|
||||
|
||||
Performs a session-based login (POST /login), then uses the
|
||||
session to create a persistent API token via POST /api/auth/tokens.
|
||||
The API token is stored in preferences for future requests.
|
||||
"""
|
||||
import http.cookiejar
|
||||
|
||||
# Build a cookie-aware opener for the session flow
|
||||
base = self.base_url
|
||||
# Strip /api suffix to get the server root for /login
|
||||
origin = base.rsplit("/api", 1)[0] if base.endswith("/api") else base
|
||||
ctx = _get_ssl_context()
|
||||
cookie_jar = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(
|
||||
urllib.request.HTTPCookieProcessor(cookie_jar),
|
||||
urllib.request.HTTPSHandler(context=ctx),
|
||||
)
|
||||
|
||||
# Step 1: POST form-encoded credentials to /login
|
||||
login_url = f"{origin}/login"
|
||||
form_data = urllib.parse.urlencode(
|
||||
{"username": username, "password": password}
|
||||
).encode()
|
||||
req = urllib.request.Request(
|
||||
login_url,
|
||||
data=form_data,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=_get_ssl_context()) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
opener.open(req)
|
||||
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}")
|
||||
if e.code in (302, 303):
|
||||
pass # Redirect after login is expected
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Login failed (HTTP {e.code}): invalid credentials or server error"
|
||||
)
|
||||
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
|
||||
# Step 2: Verify session by calling /api/auth/me
|
||||
me_url = f"{origin}/api/auth/me"
|
||||
me_req = urllib.request.Request(me_url, method="GET")
|
||||
try:
|
||||
with opener.open(me_req) as resp:
|
||||
user_info = json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise RuntimeError("Login failed: invalid username or password")
|
||||
raise RuntimeError(f"Login verification failed (HTTP {e.code})")
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"Connection error: {e.reason}")
|
||||
|
||||
# Step 3: Create a persistent API token for FreeCAD
|
||||
token_url = f"{origin}/api/auth/tokens"
|
||||
import socket
|
||||
|
||||
hostname = socket.gethostname()
|
||||
token_body = json.dumps(
|
||||
{"name": f"FreeCAD ({hostname})", "expires_in_days": 90}
|
||||
).encode()
|
||||
token_req = urllib.request.Request(
|
||||
token_url,
|
||||
data=token_body,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with opener.open(token_req) as resp:
|
||||
token_result = json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
raise RuntimeError(f"Failed to create API token (HTTP {e.code})")
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"Connection error: {e.reason}")
|
||||
|
||||
raw_token = token_result.get("token", "")
|
||||
if not raw_token:
|
||||
raise RuntimeError("Server did not return an API token")
|
||||
|
||||
# Store token and user info
|
||||
_save_auth_info(
|
||||
username=user_info.get("username", username),
|
||||
role=user_info.get("role", ""),
|
||||
source=user_info.get("auth_source", ""),
|
||||
token=raw_token,
|
||||
)
|
||||
|
||||
return {
|
||||
"username": user_info.get("username", username),
|
||||
"role": user_info.get("role", ""),
|
||||
"auth_source": user_info.get("auth_source", ""),
|
||||
"token_name": token_result.get("name", ""),
|
||||
"token_prefix": token_result.get("token_prefix", ""),
|
||||
}
|
||||
|
||||
def logout(self):
|
||||
"""Clear stored authentication credentials."""
|
||||
"""Clear stored API token and authentication info."""
|
||||
_clear_auth()
|
||||
|
||||
def is_authenticated(self) -> bool:
|
||||
"""Return True if a valid auth token is stored."""
|
||||
"""Return True if a valid API token is configured."""
|
||||
return bool(_get_auth_token())
|
||||
|
||||
def auth_username(self) -> str:
|
||||
"""Return the stored authenticated username."""
|
||||
return _get_auth_username()
|
||||
|
||||
def auth_role(self) -> str:
|
||||
"""Return the stored user role."""
|
||||
return _get_auth_role()
|
||||
|
||||
def auth_source(self) -> str:
|
||||
"""Return the stored authentication source (local, ldap, oidc)."""
|
||||
return _get_auth_source()
|
||||
|
||||
def get_current_user(self) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch the current user info from the server.
|
||||
|
||||
Returns user dict or None if not authenticated.
|
||||
"""
|
||||
try:
|
||||
return self._request("GET", "/auth/me")
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
def refresh_auth_info(self) -> bool:
|
||||
"""Refresh locally cached user info from the server.
|
||||
|
||||
Returns True if authenticated, False otherwise.
|
||||
"""
|
||||
user = self.get_current_user()
|
||||
if user and user.get("username"):
|
||||
_save_auth_info(
|
||||
username=user["username"],
|
||||
role=user.get("role", ""),
|
||||
source=user.get("auth_source", ""),
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def list_tokens(self) -> List[Dict[str, Any]]:
|
||||
"""List API tokens for the current user."""
|
||||
return self._request("GET", "/auth/tokens")
|
||||
|
||||
def create_token(
|
||||
self, name: str, expires_in_days: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a new API token.
|
||||
|
||||
Returns dict with 'token' (raw, shown once), 'id', 'name', etc.
|
||||
"""
|
||||
data: Dict[str, Any] = {"name": name}
|
||||
if expires_in_days is not None:
|
||||
data["expires_in_days"] = expires_in_days
|
||||
return self._request("POST", "/auth/tokens", data)
|
||||
|
||||
def revoke_token(self, token_id: str) -> None:
|
||||
"""Revoke an API token by its ID."""
|
||||
url = f"{self.base_url}/auth/tokens/{token_id}"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
headers.update(_get_auth_headers())
|
||||
req = urllib.request.Request(url, headers=headers, method="DELETE")
|
||||
try:
|
||||
urllib.request.urlopen(req, context=_get_ssl_context())
|
||||
except urllib.error.HTTPError as e:
|
||||
error_body = e.read().decode()
|
||||
raise RuntimeError(f"API error {e.code}: {error_body}")
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"Connection error: {e.reason}")
|
||||
|
||||
def check_connection(self) -> Tuple[bool, str]:
|
||||
"""Check connectivity to the Silo API.
|
||||
|
||||
Returns (reachable, message).
|
||||
"""
|
||||
url = f"{self.base_url}/health"
|
||||
# Use origin /health (not /api/health) since health is at root
|
||||
base = self.base_url
|
||||
origin = base.rsplit("/api", 1)[0] if base.endswith("/api") else base
|
||||
url = f"{origin}/health"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
@@ -687,7 +833,6 @@ class SiloClient:
|
||||
) 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)
|
||||
@@ -2145,31 +2290,74 @@ class Silo_Settings:
|
||||
layout.addWidget(auth_heading)
|
||||
|
||||
auth_user = _get_auth_username()
|
||||
auth_status_text = (
|
||||
f"Logged in as <b>{auth_user}</b>"
|
||||
if auth_user and _get_auth_token()
|
||||
else "Not logged in"
|
||||
)
|
||||
auth_role = _get_auth_role()
|
||||
auth_source = _get_auth_source()
|
||||
has_token = bool(_get_auth_token())
|
||||
|
||||
if has_token and auth_user:
|
||||
auth_parts = [f"Logged in as <b>{auth_user}</b>"]
|
||||
if auth_role:
|
||||
auth_parts.append(f"(role: {auth_role})")
|
||||
if auth_source:
|
||||
auth_parts.append(f"via {auth_source}")
|
||||
auth_status_text = " ".join(auth_parts)
|
||||
else:
|
||||
auth_status_text = "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)
|
||||
# API token input
|
||||
token_label = QtGui.QLabel("API Token:")
|
||||
layout.addWidget(token_label)
|
||||
|
||||
clear_auth_btn = QtGui.QPushButton("Clear Saved Credentials")
|
||||
clear_auth_btn.setEnabled(bool(_get_auth_token()))
|
||||
token_row = QtGui.QHBoxLayout()
|
||||
token_input = QtGui.QLineEdit()
|
||||
token_input.setEchoMode(QtGui.QLineEdit.Password)
|
||||
token_input.setPlaceholderText("silo_... (paste token or use Login)")
|
||||
current_token = param.GetString("ApiToken", "")
|
||||
if current_token:
|
||||
token_input.setText(current_token)
|
||||
token_row.addWidget(token_input)
|
||||
|
||||
token_show_btn = QtGui.QToolButton()
|
||||
token_show_btn.setText("\U0001f441")
|
||||
token_show_btn.setCheckable(True)
|
||||
token_show_btn.setFixedSize(28, 28)
|
||||
token_show_btn.setToolTip("Show/hide token")
|
||||
|
||||
def on_toggle_show(checked):
|
||||
if checked:
|
||||
token_input.setEchoMode(QtGui.QLineEdit.Normal)
|
||||
else:
|
||||
token_input.setEchoMode(QtGui.QLineEdit.Password)
|
||||
|
||||
token_show_btn.toggled.connect(on_toggle_show)
|
||||
token_row.addWidget(token_show_btn)
|
||||
layout.addLayout(token_row)
|
||||
|
||||
token_hint = QtGui.QLabel(
|
||||
"Paste an API token generated from the Silo web UI, "
|
||||
"or use Login in the Database Auth panel to create one "
|
||||
"automatically. Tokens can also be set via the "
|
||||
"SILO_API_TOKEN environment variable."
|
||||
)
|
||||
token_hint.setWordWrap(True)
|
||||
token_hint.setStyleSheet("color: #888; font-size: 11px;")
|
||||
layout.addWidget(token_hint)
|
||||
|
||||
layout.addSpacing(4)
|
||||
|
||||
clear_auth_btn = QtGui.QPushButton("Clear Token and Logout")
|
||||
clear_auth_btn.setEnabled(has_token)
|
||||
|
||||
def on_clear_auth():
|
||||
_clear_auth()
|
||||
token_input.setText("")
|
||||
auth_status_lbl.setText("Not logged in")
|
||||
clear_auth_btn.setEnabled(False)
|
||||
FreeCAD.Console.PrintMessage("Silo: Saved credentials cleared\n")
|
||||
FreeCAD.Console.PrintMessage("Silo: API token and credentials cleared\n")
|
||||
|
||||
clear_auth_btn.clicked.connect(on_clear_auth)
|
||||
layout.addWidget(clear_auth_btn)
|
||||
@@ -2178,11 +2366,14 @@ class Silo_Settings:
|
||||
|
||||
# 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"
|
||||
)
|
||||
if has_token and auth_user:
|
||||
auth_display = f"{auth_user} ({auth_role or 'unknown role'})"
|
||||
if auth_source:
|
||||
auth_display += f" via {auth_source}"
|
||||
elif has_token:
|
||||
auth_display = "token configured (user unknown)"
|
||||
else:
|
||||
auth_display = "not configured"
|
||||
status_label = QtGui.QLabel(
|
||||
f"<b>Active URL:</b> {_get_api_url()}<br>"
|
||||
f"<b>SSL verification:</b> {'enabled' if _get_ssl_verify() else 'disabled'}<br>"
|
||||
@@ -2209,6 +2400,18 @@ class Silo_Settings:
|
||||
param.SetBool("SslVerify", ssl_checkbox.isChecked())
|
||||
cert_path = cert_input.text().strip()
|
||||
param.SetString("SslCertPath", cert_path)
|
||||
# Save API token if changed
|
||||
new_token = token_input.text().strip()
|
||||
old_token = param.GetString("ApiToken", "")
|
||||
if new_token != old_token:
|
||||
param.SetString("ApiToken", new_token)
|
||||
if new_token and not old_token:
|
||||
FreeCAD.Console.PrintMessage("Silo: API token configured\n")
|
||||
elif not new_token and old_token:
|
||||
_clear_auth()
|
||||
FreeCAD.Console.PrintMessage("Silo: API token removed\n")
|
||||
else:
|
||||
FreeCAD.Console.PrintMessage("Silo: API token updated\n")
|
||||
FreeCAD.Console.PrintMessage(
|
||||
f"Silo settings saved. URL: {_get_api_url()}, "
|
||||
f"SSL verify: {_get_ssl_verify()}, "
|
||||
@@ -2728,6 +2931,18 @@ class SiloAuthDockWidget:
|
||||
user_row.addStretch()
|
||||
layout.addLayout(user_row)
|
||||
|
||||
# Role row
|
||||
role_row = QtGui.QHBoxLayout()
|
||||
role_row.setSpacing(6)
|
||||
role_lbl = QtGui.QLabel("Role:")
|
||||
role_lbl.setStyleSheet("color: #888;")
|
||||
self._role_label = QtGui.QLabel("")
|
||||
self._role_label.setStyleSheet("font-size: 11px;")
|
||||
role_row.addWidget(role_lbl)
|
||||
role_row.addWidget(self._role_label)
|
||||
role_row.addStretch()
|
||||
layout.addLayout(role_row)
|
||||
|
||||
layout.addSpacing(4)
|
||||
|
||||
# URL row (compact display)
|
||||
@@ -2771,13 +2986,10 @@ class SiloAuthDockWidget:
|
||||
# Update URL display
|
||||
self._url_label.setText(_get_api_url())
|
||||
|
||||
authed = _client.is_authenticated()
|
||||
has_token = _client.is_authenticated()
|
||||
username = _client.auth_username()
|
||||
|
||||
if authed and username:
|
||||
self._user_label.setText(username)
|
||||
else:
|
||||
self._user_label.setText("(not logged in)")
|
||||
role = _client.auth_role()
|
||||
source = _client.auth_source()
|
||||
|
||||
# Check server connectivity
|
||||
try:
|
||||
@@ -2785,32 +2997,53 @@ class SiloAuthDockWidget:
|
||||
except Exception:
|
||||
reachable = False
|
||||
|
||||
# If reachable and we have a token, validate it against the server
|
||||
authed = False
|
||||
if reachable and has_token:
|
||||
user = _client.get_current_user()
|
||||
if user and user.get("username"):
|
||||
authed = True
|
||||
username = user["username"]
|
||||
role = user.get("role", "")
|
||||
source = user.get("auth_source", "")
|
||||
_save_auth_info(username=username, role=role, source=source)
|
||||
|
||||
if authed:
|
||||
self._user_label.setText(username)
|
||||
role_text = role or ""
|
||||
if source:
|
||||
role_text += f" ({source})" if role_text else source
|
||||
self._role_label.setText(role_text)
|
||||
else:
|
||||
self._user_label.setText("(not logged in)")
|
||||
self._role_label.setText("")
|
||||
|
||||
# Update button state
|
||||
try:
|
||||
self._login_btn.clicked.disconnect()
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
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:
|
||||
elif reachable and has_token and not authed:
|
||||
# Token exists but is invalid/expired
|
||||
self._status_dot.setStyleSheet("color: #FF9800; font-size: 10px;")
|
||||
self._status_label.setText("Token invalid")
|
||||
self._login_btn.setText("Login")
|
||||
self._login_btn.clicked.connect(self._on_login_clicked)
|
||||
elif reachable and not has_token:
|
||||
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 ------------------------------------------------------------
|
||||
@@ -2833,7 +3066,7 @@ class SiloAuthDockWidget:
|
||||
|
||||
dialog = QtGui.QDialog(self.widget)
|
||||
dialog.setWindowTitle("Silo Login")
|
||||
dialog.setMinimumWidth(350)
|
||||
dialog.setMinimumWidth(380)
|
||||
|
||||
layout = QtGui.QVBoxLayout(dialog)
|
||||
|
||||
@@ -2842,14 +3075,23 @@ class SiloAuthDockWidget:
|
||||
server_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
layout.addWidget(server_label)
|
||||
|
||||
layout.addSpacing(4)
|
||||
|
||||
info_label = QtGui.QLabel(
|
||||
"Enter your credentials to create a persistent API token. "
|
||||
"Supports local accounts and LDAP (FreeIPA)."
|
||||
)
|
||||
info_label.setWordWrap(True)
|
||||
info_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
layout.addWidget(info_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
|
||||
user_input.setPlaceholderText("Username")
|
||||
last_user = _get_auth_username()
|
||||
if last_user:
|
||||
user_input.setText(last_user)
|
||||
@@ -2862,17 +3104,16 @@ class SiloAuthDockWidget:
|
||||
layout.addWidget(pass_label)
|
||||
pass_input = QtGui.QLineEdit()
|
||||
pass_input.setEchoMode(QtGui.QLineEdit.Password)
|
||||
pass_input.setPlaceholderText("LDAP password")
|
||||
pass_input.setPlaceholderText("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)
|
||||
# Error / status label
|
||||
status_label = QtGui.QLabel("")
|
||||
status_label.setWordWrap(True)
|
||||
status_label.setVisible(False)
|
||||
layout.addWidget(status_label)
|
||||
|
||||
layout.addSpacing(8)
|
||||
|
||||
@@ -2889,16 +3130,35 @@ class SiloAuthDockWidget:
|
||||
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)
|
||||
status_label.setText("Username and password are required.")
|
||||
status_label.setStyleSheet("color: #F44336;")
|
||||
status_label.setVisible(True)
|
||||
return
|
||||
# Disable inputs during login
|
||||
login_btn.setEnabled(False)
|
||||
status_label.setText("Logging in...")
|
||||
status_label.setStyleSheet("color: #888;")
|
||||
status_label.setVisible(True)
|
||||
# Process events so the user sees the status update
|
||||
from PySide.QtWidgets import QApplication
|
||||
|
||||
QApplication.processEvents()
|
||||
try:
|
||||
_client.login(username, password)
|
||||
FreeCAD.Console.PrintMessage(f"Silo: Logged in as {username}\n")
|
||||
result = _client.login(username, password)
|
||||
role = result.get("role", "")
|
||||
source = result.get("auth_source", "")
|
||||
msg = f"Silo: Logged in as {username}"
|
||||
if role:
|
||||
msg += f" ({role})"
|
||||
if source:
|
||||
msg += f" via {source}"
|
||||
FreeCAD.Console.PrintMessage(msg + "\n")
|
||||
dialog.accept()
|
||||
except RuntimeError as e:
|
||||
error_label.setText(str(e))
|
||||
error_label.setVisible(True)
|
||||
status_label.setText(str(e))
|
||||
status_label.setStyleSheet("color: #F44336;")
|
||||
status_label.setVisible(True)
|
||||
login_btn.setEnabled(True)
|
||||
|
||||
login_btn.clicked.connect(on_login)
|
||||
cancel_btn.clicked.connect(dialog.reject)
|
||||
|
||||
Reference in New Issue
Block a user