From b894123aa879880b45d93a7321bc376b953aa1f7 Mon Sep 17 00:00:00 2001 From: Cristian Pufu Date: Sun, 22 Feb 2026 06:53:08 +0200 Subject: [PATCH] feat: add interactive OAuth authentication to dev server Implements PKCE-based OAuth flow matching the Python SDK's `uipath auth` CLI command. A temporary callback server receives the token from the browser, resolves tenants, and writes credentials to .env and os.environ. Includes session persistence across restarts, automatic token expiry detection with re-auth support, and a configurable flag to disable auth in deployments. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 1 + pyproject.toml | 2 +- src/uipath/dev/server/app.py | 19 + src/uipath/dev/server/auth.py | 1127 +++++++++++++++++ src/uipath/dev/server/frontend/src/App.tsx | 7 +- .../dev/server/frontend/src/api/auth.ts | 35 + .../src/components/layout/Sidebar.tsx | 184 ++- .../src/components/runs/NewRunPanel.tsx | 17 + .../server/frontend/src/store/useAuthStore.ts | 167 +++ .../dev/server/frontend/tsconfig.tsbuildinfo | 2 +- src/uipath/dev/server/routes/auth.py | 57 + ...anel-DvMLkYmo.js => ChatPanel-0HELh5u1.js} | 2 +- .../server/static/assets/index-AXT426N7.js | 42 - .../server/static/assets/index-BZ-OsryT.js | 42 + .../server/static/assets/index-CwdGgmxN.css | 1 + .../server/static/assets/index-Dp7Ms2kW.css | 1 - src/uipath/dev/server/static/index.html | 4 +- tests/e2e/test_web_run.py | 6 +- uv.lock | 2 +- 19 files changed, 1636 insertions(+), 82 deletions(-) create mode 100644 src/uipath/dev/server/auth.py create mode 100644 src/uipath/dev/server/frontend/src/api/auth.ts create mode 100644 src/uipath/dev/server/frontend/src/store/useAuthStore.ts create mode 100644 src/uipath/dev/server/routes/auth.py rename src/uipath/dev/server/static/assets/{ChatPanel-DvMLkYmo.js => ChatPanel-0HELh5u1.js} (99%) delete mode 100644 src/uipath/dev/server/static/assets/index-AXT426N7.js create mode 100644 src/uipath/dev/server/static/assets/index-BZ-OsryT.js create mode 100644 src/uipath/dev/server/static/assets/index-CwdGgmxN.css delete mode 100644 src/uipath/dev/server/static/assets/index-Dp7Ms2kW.css diff --git a/Dockerfile b/Dockerfile index d40b565..fad58e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,5 +9,6 @@ RUN chmod +x start.sh ENV UIPATH_DEV_SERVER_PORT=80 ENV UIPATH_DEV_SERVER_HOST=0.0.0.0 +ENV UIPATH_AUTH_ENABLED=false CMD ["/bin/sh", "/app/start.sh"] diff --git a/pyproject.toml b/pyproject.toml index 3ee1904..0948d99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-dev" -version = "0.0.56" +version = "0.0.57" description = "UiPath Developer Console" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/dev/server/app.py b/src/uipath/dev/server/app.py index 542582c..7f5df68 100644 --- a/src/uipath/dev/server/app.py +++ b/src/uipath/dev/server/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import os from pathlib import Path from fastapi import FastAPI @@ -129,6 +130,17 @@ async def _favicon_svg_route(): # Store server reference on app state for route access app.state.server = server + auth_enabled = os.environ.get("UIPATH_AUTH_ENABLED", "true").lower() not in ( + "false", + "0", + "no", + ) + + # Config endpoint — tells the frontend which features are available + @app.get("/api/config", include_in_schema=False) + async def _config(): + return {"auth_enabled": auth_enabled} + # Register routes from uipath.dev.server.routes.entrypoints import router as entrypoints_router from uipath.dev.server.routes.graph import router as graph_router @@ -136,6 +148,13 @@ async def _favicon_svg_route(): from uipath.dev.server.routes.runs import router as runs_router from uipath.dev.server.ws.handler import router as ws_router + if auth_enabled: + from uipath.dev.server.auth import restore_session + from uipath.dev.server.routes.auth import router as auth_router + + app.include_router(auth_router, prefix="/api") + restore_session() + app.include_router(entrypoints_router, prefix="/api") app.include_router(runs_router, prefix="/api") app.include_router(graph_router, prefix="/api") diff --git a/src/uipath/dev/server/auth.py b/src/uipath/dev/server/auth.py new file mode 100644 index 0000000..c76dd54 --- /dev/null +++ b/src/uipath/dev/server/auth.py @@ -0,0 +1,1127 @@ +"""Interactive OAuth authentication service for the dev server. + +Implements the same PKCE-based authorization code flow as the Python SDK's +``uipath auth`` CLI command. A temporary callback server is spun up on one of +the registered redirect-URI ports (8104, 8055, 42042) to receive the token from +the browser. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import http.server +import json +import logging +import os +import socketserver +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urlencode + +import httpx + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- +CLIENT_ID = "36dea5b8-e8bb-423d-8e7b-c808df8f1c00" +SCOPES = ( + "offline_access ProcessMining OrchestratorApiUserAccess StudioWebBackend " + "IdentityServerApi ConnectionService DataService DocumentUnderstanding " + "Du.Digitization.Api Du.Classification.Api Du.Extraction.Api " + "Du.Validation.Api EnterpriseContextService Directory JamJamApi " + "LLMGateway LLMOps OMS RCS.FolderAuthorization TM.Projects " + "TM.TestCases TM.Requirements TM.TestSets AutomationSolutions" +) +CANDIDATE_PORTS = [8104, 8055, 42042] + +# --------------------------------------------------------------------------- +# PKCE helpers +# --------------------------------------------------------------------------- + + +def _generate_pkce() -> tuple[str, str]: + """Return (code_verifier, code_challenge) for PKCE S256.""" + verifier = base64.urlsafe_b64encode(os.urandom(32)).decode().rstrip("=") + challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()) + .decode() + .rstrip("=") + ) + return verifier, challenge + + +def _generate_state() -> str: + return base64.urlsafe_b64encode(os.urandom(32)).decode().rstrip("=") + + +def _parse_jwt_payload(token: str) -> dict[str, Any]: + """Decode the payload section of a JWT (no signature verification).""" + parts = token.split(".") + if len(parts) < 2: + raise ValueError("Invalid JWT") + padded = parts[1] + "=" * (-len(parts[1]) % 4) + return json.loads(base64.urlsafe_b64decode(padded)) + + +# --------------------------------------------------------------------------- +# Port finder +# --------------------------------------------------------------------------- + + +def _try_bind_server( + candidates: list[int], handler_class: type +) -> socketserver.TCPServer | None: + """Try to bind a TCPServer on the first available candidate port.""" + socketserver.TCPServer.allow_reuse_address = True + for port in candidates: + try: + httpd = socketserver.TCPServer(("127.0.0.1", port), handler_class) + return httpd + except OSError: + continue + return None + + +# --------------------------------------------------------------------------- +# Auth state +# --------------------------------------------------------------------------- + + +@dataclass +class AuthState: + """Mutable state for the in-progress or completed OAuth flow.""" + + status: str = "unauthenticated" # unauthenticated | pending | needs_tenant | authenticated | expired + environment: str = "cloud" + token_data: dict[str, Any] = field(default_factory=dict) + tenants: list[dict[str, str]] = field(default_factory=list) + organization: dict[str, str] = field(default_factory=dict) + uipath_url: str | None = None + # Remembered from last session for seamless re-auth + _last_tenant: str | None = None + _last_org: dict[str, str] = field(default_factory=dict) + _last_environment: str | None = None + # internal + _code_verifier: str | None = None + _state: str | None = None + _port: int | None = None + _callback_server: _CallbackServer | None = None + _token_event: asyncio.Event | None = None + _loop: asyncio.AbstractEventLoop | None = None + _wait_task: asyncio.Task[None] | None = None + + +_auth = AuthState() + + +def get_auth_state() -> AuthState: + """Return the module-level auth state singleton.""" + return _auth + + +def reset_auth_state() -> None: + """Reset the auth state to its initial (unauthenticated) values.""" + global _auth + _auth = AuthState() + + +# --------------------------------------------------------------------------- +# Callback HTML (adapted from SDK index.html) +# --------------------------------------------------------------------------- + +_CALLBACK_HTML = """\ + + + + + + + UiPath CLI Authentication + + + + + + +
+
+ + +
+

Authenticate CLI

+

Completing authentication flow...

+
+ +
+
+
+
+
×
+
+ +
+
+
+
+
+ +
+ Processing authentication request... +
+
+ +
+ Authenticating...
+ Securely exchanging authorization code for access tokens. +
+ +
+ +
+
+
+
+ + + + + +""" + +# --------------------------------------------------------------------------- +# Callback HTTP server +# --------------------------------------------------------------------------- + + +def _make_handler( + html: str, + port: int, + csrf_state: str, + token_callback: Any, +) -> type: + """Build the HTTP request handler class with closures over request params.""" + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, fmt: str, *args: Any) -> None: + pass + + def do_GET(self) -> None: + content = html.encode() + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(content))) + self._cors() + self.end_headers() + self.wfile.write(content) + + def do_POST(self) -> None: + if self.path == f"/set_token/{csrf_state}": + length = int(self.headers.get("Content-Length", 0)) + try: + body = json.loads(self.rfile.read(length)) + except (json.JSONDecodeError, ValueError): + self.send_error(400, "Malformed JSON") + return + self.send_response(200) + self._cors() + self.end_headers() + self.wfile.write(b"OK") + # Small delay so the browser gets the response before we shut down + time.sleep(0.5) + token_callback(body) + else: + self.send_error(404) + + def do_OPTIONS(self) -> None: + self.send_response(200) + self._cors() + self.end_headers() + + def _cors(self) -> None: + origin = f"http://localhost:{port}" + self.send_header("Access-Control-Allow-Origin", origin) + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + + return Handler + + +class _CallbackServer: + """Temporary HTTP server that receives the OAuth token from the browser.""" + + def __init__(self, httpd: socketserver.TCPServer) -> None: + self.httpd: socketserver.TCPServer | None = httpd + self.port: int = httpd.server_address[1] + self._thread: threading.Thread | None = None + self._shutdown = False + + def start(self) -> None: + self._shutdown = False + self._thread = threading.Thread(target=self._serve, daemon=True) + self._thread.start() + + def _serve(self) -> None: + try: + while not self._shutdown and self.httpd: + self.httpd.handle_request() + except Exception: + pass + + def stop(self) -> None: + self._shutdown = True + if self.httpd: + self.httpd.server_close() + self.httpd = None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +_VALID_ENVIRONMENTS = {"cloud", "staging", "alpha"} + + +def build_auth_url(environment: str) -> dict[str, Any]: + """Start the OAuth flow: generate PKCE, spin up callback server, return auth URL.""" + if environment not in _VALID_ENVIRONMENTS: + raise ValueError( + f"Invalid environment '{environment}'. Must be one of: {', '.join(sorted(_VALID_ENVIRONMENTS))}" + ) + + auth = get_auth_state() + + # Stop any previous callback server and cancel any pending wait task + if auth._callback_server: + auth._callback_server.stop() + if auth._wait_task and not auth._wait_task.done(): + auth._wait_task.cancel() + + verifier, challenge = _generate_pkce() + state = _generate_state() + domain = f"https://{environment}.uipath.com" + + # Build handler and bind server atomically (no TOCTOU race) + # We need the port first to construct the HTML, so we do a two-step: + # 1. Build a temporary handler, bind to get the port + # 2. Rebuild the handler with the correct HTML, then swap + def on_token(token_data: dict[str, Any]) -> None: + auth.token_data = token_data + if auth._loop and auth._token_event: + auth._loop.call_soon_threadsafe(auth._token_event.set) + + # _make_handler needs the port for CORS, and the HTML needs the port for + # redirect_uri. We build a placeholder handler to bind, then rebuild. + placeholder = _make_handler("", 0, state, on_token) + httpd = _try_bind_server(CANDIDATE_PORTS, placeholder) + if httpd is None: + raise RuntimeError( + f"All callback ports ({', '.join(str(p) for p in CANDIDATE_PORTS)}) are in use" + ) + + port = httpd.server_address[1] + redirect_uri = f"http://localhost:{port}/oidc/login" + + html = ( + _CALLBACK_HTML.replace("__STATE__", state) + .replace("__CODE_VERIFIER__", verifier) + .replace("__REDIRECT_URI__", redirect_uri) + .replace("__CLIENT_ID__", CLIENT_ID) + .replace("__DOMAIN__", domain) + ) + + # Replace the handler with the real one containing the correct HTML/port + httpd.RequestHandlerClass = _make_handler(html, port, state, on_token) + + query = urlencode( + { + "client_id": CLIENT_ID, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": SCOPES, + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + } + ) + auth_url = f"{domain}/identity_/connect/authorize?{query}" + + # Store state for later (preserve remembered tenant info for re-auth) + auth.status = "pending" + auth.environment = environment + auth._code_verifier = verifier + auth._state = state + auth._port = port + + # Set up asyncio event for signalling + loop = asyncio.get_running_loop() + auth._token_event = asyncio.Event() + auth._loop = loop + + server = _CallbackServer(httpd) + server.start() + auth._callback_server = server + + auth._wait_task = asyncio.ensure_future(_wait_for_token(auth)) + + return {"auth_url": auth_url, "status": "pending"} + + +async def _wait_for_token(auth: AuthState) -> None: + """Wait for the callback server to receive the token, then resolve tenants.""" + try: + if auth._token_event: + await asyncio.wait_for(auth._token_event.wait(), timeout=300) + except asyncio.TimeoutError: + logger.warning("OAuth flow timed out after 5 minutes") + auth.status = "unauthenticated" + return + finally: + if auth._callback_server: + auth._callback_server.stop() + auth._callback_server = None + + if not auth.token_data: + auth.status = "unauthenticated" + return + + # Resolve tenants + try: + domain = f"https://{auth.environment}.uipath.com" + access_token = auth.token_data.get("access_token", "") + claims = _parse_jwt_payload(access_token) + prt_id = claims.get("prt_id", "") + + url = f"{domain}/{prt_id}/portal_/api/filtering/leftnav/tenantsAndOrganizationInfo" + async with httpx.AsyncClient() as client: + resp = await client.get( + url, headers={"Authorization": f"Bearer {access_token}"} + ) + resp.raise_for_status() + data = resp.json() + + auth.tenants = data.get("tenants", []) + auth.organization = data.get("organization", {}) + + tenant_names = [t["name"] for t in auth.tenants] + + if auth._last_tenant and auth._last_tenant in tenant_names: + # Re-auth: auto-select the previously used tenant + _finalize_tenant(auth, auth._last_tenant) + elif len(auth.tenants) == 1: + # Auto-select single tenant + _finalize_tenant(auth, auth.tenants[0]["name"]) + else: + auth.status = "needs_tenant" + except Exception: + logger.exception("Failed to resolve tenants") + auth.status = "unauthenticated" + + +def select_tenant(tenant_name: str) -> dict[str, Any]: + """Select a tenant and finalize authentication.""" + auth = get_auth_state() + tenant = next((t for t in auth.tenants if t["name"] == tenant_name), None) + if not tenant: + raise ValueError(f"Tenant '{tenant_name}' not found") + _finalize_tenant(auth, tenant_name) + return {"status": "authenticated", "uipath_url": auth.uipath_url} + + +def _finalize_tenant(auth: AuthState, tenant_name: str) -> None: + """Write .env and os.environ with the resolved credentials.""" + org_name = auth.organization.get("name", "") + domain = f"https://{auth.environment}.uipath.com" + uipath_url = f"{domain}/{org_name}/{tenant_name}" + access_token = auth.token_data.get("access_token", "") + + auth.uipath_url = uipath_url + auth.status = "authenticated" + + # Remember for seamless re-auth after expiry + auth._last_tenant = tenant_name + auth._last_org = dict(auth.organization) + auth._last_environment = auth.environment + + # Update os.environ + os.environ["UIPATH_ACCESS_TOKEN"] = access_token + os.environ["UIPATH_URL"] = uipath_url + + # Write/update .env file (preserving comments, blank lines, and ordering) + env_path = Path.cwd() / ".env" + lines: list[str] = [] + updated_keys: set[str] = set() + new_values = {"UIPATH_ACCESS_TOKEN": access_token, "UIPATH_URL": uipath_url} + + if env_path.exists(): + with open(env_path) as f: + for raw_line in f: + stripped = raw_line.strip() + if "=" in stripped and not stripped.startswith("#"): + key = stripped.split("=", 1)[0] + if key in new_values: + lines.append(f"{key}={new_values[key]}\n") + updated_keys.add(key) + continue + lines.append(raw_line) + + # Append any keys that weren't already in the file + for key, value in new_values.items(): + if key not in updated_keys: + lines.append(f"{key}={value}\n") + + with open(env_path, "w") as f: + f.writelines(lines) + + +def logout() -> None: + """Clear auth state and env vars.""" + auth = get_auth_state() + if auth._callback_server: + auth._callback_server.stop() + + os.environ.pop("UIPATH_ACCESS_TOKEN", None) + os.environ.pop("UIPATH_URL", None) + + reset_auth_state() + + +def _check_token_expiry(auth: AuthState) -> None: + """Flip status to 'expired' if the current token has expired.""" + if auth.status != "authenticated": + return + access_token = auth.token_data.get("access_token", "") + if not access_token: + return + try: + claims = _parse_jwt_payload(access_token) + exp = claims.get("exp") + if exp is not None and float(exp) < time.time(): + auth.status = "expired" + except Exception: + pass + + +def get_status() -> dict[str, Any]: + """Return current auth status for the frontend.""" + auth = get_auth_state() + + _check_token_expiry(auth) + + result: dict[str, Any] = {"status": auth.status} + + if auth.status == "needs_tenant": + result["tenants"] = [t["name"] for t in auth.tenants] + + if auth.status in ("authenticated", "expired"): + result["uipath_url"] = auth.uipath_url + + return result + + +def restore_session() -> None: + """Check env/.env for existing credentials and restore auth state if valid.""" + auth = get_auth_state() + if auth.status != "unauthenticated": + return + + # Try os.environ first, then .env file + access_token = os.environ.get("UIPATH_ACCESS_TOKEN", "") + uipath_url = os.environ.get("UIPATH_URL", "") + + if not access_token or not uipath_url: + # Try reading from .env + env_path = Path.cwd() / ".env" + if env_path.exists(): + env_vars: dict[str, str] = {} + with open(env_path) as f: + for line in f: + line = line.strip() + if "=" in line and not line.startswith("#"): + key, value = line.split("=", 1) + env_vars[key.strip()] = value.strip() + access_token = access_token or env_vars.get("UIPATH_ACCESS_TOKEN", "") + uipath_url = uipath_url or env_vars.get("UIPATH_URL", "") + + if not access_token or not uipath_url: + return + + # Check token expiry + try: + claims = _parse_jwt_payload(access_token) + exp = claims.get("exp") + if exp is not None and float(exp) < time.time(): + logger.debug("Existing token is expired, skipping restore") + return + except Exception: + return + + # Token is valid — restore state + auth.status = "authenticated" + auth.uipath_url = uipath_url + auth.token_data = {"access_token": access_token} + + # Ensure os.environ is populated + os.environ["UIPATH_ACCESS_TOKEN"] = access_token + os.environ["UIPATH_URL"] = uipath_url diff --git a/src/uipath/dev/server/frontend/src/App.tsx b/src/uipath/dev/server/frontend/src/App.tsx index 183b76c..ef3d312 100644 --- a/src/uipath/dev/server/frontend/src/App.tsx +++ b/src/uipath/dev/server/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRunStore } from "./store/useRunStore"; +import { useAuthStore } from "./store/useAuthStore"; import { useWebSocket } from "./store/useWebSocket"; import { listRuns, listEntrypoints, getRun } from "./api/client"; import type { RunDetail } from "./types/run"; @@ -39,13 +40,15 @@ export default function App() { } }, [view, routeRunId, selectedRunId, selectRun]); - // Load existing runs and entrypoints on mount + // Load existing runs, entrypoints, and auth status on mount + const initAuth = useAuthStore((s) => s.init); useEffect(() => { listRuns().then(setRuns).catch(console.error); listEntrypoints() .then((eps) => setEntrypoints(eps.map((e) => e.name))) .catch(console.error); - }, [setRuns, setEntrypoints]); + initAuth(); + }, [setRuns, setEntrypoints, initAuth]); const selectedRun = selectedRunId ? runs[selectedRunId] : null; diff --git a/src/uipath/dev/server/frontend/src/api/auth.ts b/src/uipath/dev/server/frontend/src/api/auth.ts new file mode 100644 index 0000000..344c6a9 --- /dev/null +++ b/src/uipath/dev/server/frontend/src/api/auth.ts @@ -0,0 +1,35 @@ +const BASE = "/api"; + +export async function startLogin(environment: string): Promise<{ auth_url: string; status: string }> { + const res = await fetch(`${BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ environment }), + }); + if (!res.ok) throw new Error(`Login failed: ${res.status}`); + return res.json(); +} + +export async function getAuthStatus(): Promise<{ + status: "unauthenticated" | "pending" | "needs_tenant" | "authenticated" | "expired"; + tenants?: string[]; + uipath_url?: string; +}> { + const res = await fetch(`${BASE}/auth/status`); + if (!res.ok) throw new Error(`Status check failed: ${res.status}`); + return res.json(); +} + +export async function selectTenant(tenantName: string): Promise<{ status: string; uipath_url: string }> { + const res = await fetch(`${BASE}/auth/select-tenant`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tenant_name: tenantName }), + }); + if (!res.ok) throw new Error(`Tenant selection failed: ${res.status}`); + return res.json(); +} + +export async function logout(): Promise { + await fetch(`${BASE}/auth/logout`, { method: "POST" }); +} diff --git a/src/uipath/dev/server/frontend/src/components/layout/Sidebar.tsx b/src/uipath/dev/server/frontend/src/components/layout/Sidebar.tsx index 1265289..ab5f4ef 100644 --- a/src/uipath/dev/server/frontend/src/components/layout/Sidebar.tsx +++ b/src/uipath/dev/server/frontend/src/components/layout/Sidebar.tsx @@ -1,5 +1,7 @@ +import { useState } from "react"; import type { RunSummary } from "../../types/run"; import { useTheme } from "../../store/useTheme"; +import { useAuthStore } from "../../store/useAuthStore"; import RunHistoryItem from "../runs/RunHistoryItem"; interface Props { @@ -114,21 +116,8 @@ export default function Sidebar({ runs, selectedRunId, onSelectRun, onNewRun, is )} - {/* GitHub link */} -
- - - - - GitHub - -
+ {/* Auth section */} + ); @@ -214,21 +203,160 @@ export default function Sidebar({ runs, selectedRunId, onSelectRun, onNewRun, is )} - {/* GitHub link */} -
- + + ); +} + +function AuthFooter() { + const { enabled, status, environment, tenants, uipathUrl, setEnvironment, startLogin, selectTenant, logout } = useAuthStore(); + const [selectedTenant, setSelectedTenant] = useState(""); + + if (!enabled) return null; + + if (status === "authenticated" || status === "expired") { + // Truncate URL for display: show org/tenant part only + const shortUrl = uipathUrl + ? uipathUrl.replace(/^https?:\/\/[^/]+\//, "") + : ""; + const isExpired = status === "expired"; + return ( +
+
+ {isExpired ? ( + + ) : ( + +
+ + {shortUrl} + + + )} + +
+
+ ); + } + + if (status === "pending") { + return ( +
+ + + + + + Signing in… + +
+ ); + } + + if (status === "needs_tenant") { + return ( +
+ + +
- + ); + } + + // Unauthenticated + return ( +
+ + +
); } diff --git a/src/uipath/dev/server/frontend/src/components/runs/NewRunPanel.tsx b/src/uipath/dev/server/frontend/src/components/runs/NewRunPanel.tsx index 00562a8..d5bba3b 100644 --- a/src/uipath/dev/server/frontend/src/components/runs/NewRunPanel.tsx +++ b/src/uipath/dev/server/frontend/src/components/runs/NewRunPanel.tsx @@ -72,6 +72,7 @@ export default function NewRunPanel() { Entrypoint