Add DocuSign account picker

This commit is contained in:
Paul Huliganga 2026-04-21 23:06:48 -04:00
parent af92aa6c47
commit 90113a6514
13 changed files with 579 additions and 57 deletions

View File

@ -133,6 +133,7 @@ Important behavior:
- the CLI still stores DocuSign tokens in `.env` - the CLI still stores DocuSign tokens in `.env`
- the web UI does **not** reuse `.env` DocuSign refresh tokens for all users anymore - the web UI does **not** reuse `.env` DocuSign refresh tokens for all users anymore
- each tester who needs DocuSign upload/verification should connect DocuSign in their own browser session - each tester who needs DocuSign upload/verification should connect DocuSign in their own browser session
- if a DocuSign user belongs to multiple accounts, the web UI fetches the full account list from `/oauth/userinfo`, sorts it alphabetically, and requires the user to choose an account for the session
- browser-session files live under `.session-store/` by default and can be deleted to force reconnects - browser-session files live under `.session-store/` by default and can be deleted to force reconnects
### Navigation ### Navigation
@ -151,7 +152,8 @@ Important behavior:
1. **Create a project** — the switcher modal opens on first run; name it after the customer. 1. **Create a project** — the switcher modal opens on first run; name it after the customer.
2. **Connect platforms** — click the Adobe Sign and Docusign chips in the top bar. 2. **Connect platforms** — click the Adobe Sign and Docusign chips in the top bar.
- For group testing, each tester should connect Docusign in their own browser. - For group testing, each tester should connect Docusign in their own browser.
- Settings now shows the browser session ID and auth mode for easier troubleshooting. - If your DocuSign login belongs to multiple accounts, the app will prompt you to choose one account for this session.
- Settings now shows the browser session ID, auth mode, and selected DocuSign account for easier troubleshooting.
3. **Review templates** — the Templates view shows readiness badges: 3. **Review templates** — the Templates view shows readiness badges:
- **Ready** (green) — no issues, safe to migrate - **Ready** (green) — no issues, safe to migrate
- **Caveats** (amber) — warnings exist; migration will proceed but check Issues view - **Caveats** (amber) — warnings exist; migration will proceed but check Issues view

View File

@ -17,6 +17,27 @@ from web.session import create_test_session
client = TestClient(app, raise_server_exceptions=True) client = TestClient(app, raise_server_exceptions=True)
def _userinfo_payload():
return {
"name": "Paul Example",
"email": "paul@example.com",
"accounts": [
{
"account_id": "bbb-account",
"account_name": "Zulu Team",
"base_uri": "https://na3.docusign.net",
"is_default": False,
},
{
"account_id": "aaa-account",
"account_name": "Alpha Team",
"base_uri": "https://na2.docusign.net",
"is_default": True,
},
],
}
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def temp_session_store(tmp_path, monkeypatch): def temp_session_store(tmp_path, monkeypatch):
import web.config as cfg import web.config as cfg
@ -107,20 +128,23 @@ def test_adobe_exchange_rejects_missing_code():
def test_docusign_connect_stores_token(): def test_docusign_connect_stores_token():
"""GET /api/auth/docusign/connect refreshes the current session's token.""" """GET /api/auth/docusign/connect refreshes the current session's token."""
from unittest.mock import patch from unittest.mock import AsyncMock, patch
cookie = create_test_session({"docusign_refresh_token": "refresh-123"}) cookie = create_test_session({"docusign_refresh_token": "refresh-123"})
with patch("docusign_auth.refresh_access_token", return_value={"access_token": "ds-oauth-token", "refresh_token": "refresh-456", "expires_in": 3600}): with patch("docusign_auth.refresh_access_token", return_value={"access_token": "ds-oauth-token", "refresh_token": "refresh-456", "expires_in": 3600}), \
patch("web.routers.auth.fetch_userinfo", new=AsyncMock(return_value=_userinfo_payload())):
resp = client.get("/api/auth/docusign/connect", cookies={"migrator_session": cookie}) resp = client.get("/api/auth/docusign/connect", cookies={"migrator_session": cookie})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["connected"] is True assert resp.json()["connected"] is True
assert resp.json()["account_selection_required"] is True
session_cookie = resp.cookies.get("migrator_session") session_cookie = resp.cookies.get("migrator_session")
assert session_cookie is not None assert session_cookie is not None
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie}) status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
assert status_resp.json()["docusign"] is True assert status_resp.json()["docusign"] is True
assert status_resp.json()["docusign_account_selection_required"] is True
def test_docusign_connect_requests_authorization_when_refresh_token_missing(): def test_docusign_connect_requests_authorization_when_refresh_token_missing():
@ -149,13 +173,13 @@ def test_docusign_callback_requires_matching_state():
def test_docusign_callback_stores_per_session_tokens(): def test_docusign_callback_stores_per_session_tokens():
"""DocuSign callback stores refresh/access tokens in this browser session only.""" """DocuSign callback stores refresh/access tokens in this browser session only."""
from unittest.mock import patch from unittest.mock import AsyncMock, patch
cookie = create_test_session({"docusign_oauth_state": "expected-state"}) cookie = create_test_session({"docusign_oauth_state": "expected-state"})
with patch( with patch(
"docusign_auth.exchange_code_for_token", "docusign_auth.exchange_code_for_token",
return_value={"access_token": "access-123", "refresh_token": "refresh-123", "expires_in": 3600}, return_value={"access_token": "access-123", "refresh_token": "refresh-123", "expires_in": 3600},
): ), patch("web.routers.auth.fetch_userinfo", new=AsyncMock(return_value=_userinfo_payload())):
resp = client.get( resp = client.get(
"/api/auth/docusign/callback?code=authcode123&state=expected-state", "/api/auth/docusign/callback?code=authcode123&state=expected-state",
cookies={"migrator_session": cookie}, cookies={"migrator_session": cookie},
@ -167,11 +191,12 @@ def test_docusign_callback_stores_per_session_tokens():
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie}) status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
assert status_resp.json()["docusign"] is True assert status_resp.json()["docusign"] is True
assert status_resp.json()["docusign_auth_mode"] == "session_oauth" assert status_resp.json()["docusign_auth_mode"] == "session_oauth"
assert status_resp.json()["docusign_account_selection_required"] is True
def test_docusign_sessions_are_isolated(): def test_docusign_sessions_are_isolated():
"""One tester's DocuSign connection does not authenticate another tester.""" """One tester's DocuSign connection does not authenticate another tester."""
from unittest.mock import patch from unittest.mock import AsyncMock, patch
session_a = create_test_session({"docusign_oauth_state": "state-a"}) session_a = create_test_session({"docusign_oauth_state": "state-a"})
session_b = create_test_session({}) session_b = create_test_session({})
@ -179,7 +204,7 @@ def test_docusign_sessions_are_isolated():
with patch( with patch(
"docusign_auth.exchange_code_for_token", "docusign_auth.exchange_code_for_token",
return_value={"access_token": "access-a", "refresh_token": "refresh-a", "expires_in": 3600}, return_value={"access_token": "access-a", "refresh_token": "refresh-a", "expires_in": 3600},
): ), patch("web.routers.auth.fetch_userinfo", new=AsyncMock(return_value=_userinfo_payload())):
with TestClient(app, raise_server_exceptions=True) as client_a: with TestClient(app, raise_server_exceptions=True) as client_a:
callback_resp = client_a.get( callback_resp = client_a.get(
"/api/auth/docusign/callback?code=authcode123&state=state-a", "/api/auth/docusign/callback?code=authcode123&state=state-a",
@ -196,6 +221,40 @@ def test_docusign_sessions_are_isolated():
assert status_b.json()["docusign"] is False assert status_b.json()["docusign"] is False
def test_docusign_accounts_are_sorted_and_selectable():
"""Account picker returns alphabetically sorted accounts and stores the user's selection."""
from unittest.mock import AsyncMock, patch
cookie = create_test_session({"docusign_oauth_state": "expected-state"})
with patch(
"docusign_auth.exchange_code_for_token",
return_value={"access_token": "access-123", "refresh_token": "refresh-123", "expires_in": 3600},
), patch("web.routers.auth.fetch_userinfo", new=AsyncMock(return_value=_userinfo_payload())):
resp = client.get(
"/api/auth/docusign/callback?code=authcode123&state=expected-state",
cookies={"migrator_session": cookie},
follow_redirects=False,
)
session_cookie = resp.cookies.get("migrator_session")
accounts_resp = client.get("/api/auth/docusign/accounts", cookies={"migrator_session": session_cookie})
assert accounts_resp.status_code == 200
accounts = accounts_resp.json()["accounts"]
assert [a["account_name"] for a in accounts] == ["Alpha Team", "Zulu Team"]
assert accounts_resp.json()["selection_required"] is True
select_resp = client.post(
"/api/auth/docusign/account-select",
json={"account_id": "aaa-account"},
cookies={"migrator_session": session_cookie},
)
assert select_resp.status_code == 200
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
assert status_resp.json()["docusign_account_id"] == "aaa-account"
assert status_resp.json()["docusign_account_name"] == "Alpha Team"
assert status_resp.json()["docusign_account_selection_required"] is False
@respx.mock @respx.mock
def test_disconnect_clears_token(): def test_disconnect_clears_token():
"""After disconnect, status shows platform as disconnected.""" """After disconnect, status shows platform as disconnected."""

160
web/docusign_context.py Normal file
View File

@ -0,0 +1,160 @@
"""
Helpers for DocuSign session/account context.
DocuSign users can belong to multiple accounts, each with its own account_id and
base_uri. We fetch that account list from /oauth/userinfo and store it in the
browser session so the UI can present an account picker.
"""
from __future__ import annotations
from typing import Any
import httpx
from web.config import settings
class DocusignContextError(RuntimeError):
"""Raised when the current session is missing required DocuSign context."""
def __init__(self, message: str, *, status_code: int = 400, code: str = "docusign_context_error"):
super().__init__(message)
self.status_code = status_code
self.code = code
def userinfo_url() -> str:
return f"https://{settings.docusign_auth_server}/oauth/userinfo"
async def fetch_userinfo(access_token: str) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
resp = await client.get(
userinfo_url(),
headers={"Authorization": f"Bearer {access_token}"},
)
if not resp.is_success:
raise DocusignContextError(
f"DocuSign userinfo failed ({resp.status_code})",
status_code=502,
code="userinfo_failed",
)
return resp.json()
def normalize_accounts(userinfo: dict[str, Any]) -> list[dict[str, Any]]:
accounts = []
for raw in userinfo.get("accounts", []):
account_id = raw.get("account_id") or raw.get("accountId")
base_uri = (raw.get("base_uri") or raw.get("baseUri") or "").rstrip("/")
if not account_id or not base_uri:
continue
accounts.append({
"account_id": account_id,
"account_name": raw.get("account_name") or raw.get("accountName") or account_id,
"base_uri": base_uri,
"base_url": f"{base_uri}/restapi",
"is_default": bool(raw.get("is_default") or raw.get("isDefault")),
"organization_name": raw.get("organization_name") or raw.get("organizationName"),
})
accounts.sort(key=lambda item: ((item.get("account_name") or "").lower(), item["account_id"].lower()))
return accounts
def merge_userinfo(session: dict[str, Any], userinfo: dict[str, Any]) -> dict[str, Any]:
updated = dict(session)
updated["docusign_user_name"] = userinfo.get("name")
updated["docusign_user_email"] = userinfo.get("email")
updated["docusign_accounts"] = normalize_accounts(userinfo)
updated["docusign_accounts_count"] = len(updated["docusign_accounts"])
selected_id = updated.get("docusign_selected_account_id")
if selected_id:
selected = find_account(updated, selected_id)
if selected:
_apply_selected_account(updated, selected)
else:
clear_selected_account(updated)
if updated["docusign_accounts_count"] == 1:
_apply_selected_account(updated, updated["docusign_accounts"][0])
return updated
def find_account(session: dict[str, Any], account_id: str) -> dict[str, Any] | None:
for account in session.get("docusign_accounts", []):
if account.get("account_id") == account_id:
return account
return None
def _apply_selected_account(session: dict[str, Any], account: dict[str, Any]) -> None:
session["docusign_selected_account_id"] = account["account_id"]
session["docusign_selected_account_name"] = account.get("account_name")
session["docusign_selected_base_uri"] = account.get("base_uri")
session["docusign_selected_base_url"] = account.get("base_url")
def clear_selected_account(session: dict[str, Any]) -> None:
session.pop("docusign_selected_account_id", None)
session.pop("docusign_selected_account_name", None)
session.pop("docusign_selected_base_uri", None)
session.pop("docusign_selected_base_url", None)
def select_account(session: dict[str, Any], account_id: str) -> dict[str, Any]:
account = find_account(session, account_id)
if not account:
raise DocusignContextError(
"DocuSign account not found in this session.",
status_code=404,
code="account_not_found",
)
updated = dict(session)
_apply_selected_account(updated, account)
return updated
def account_picker_required(session: dict[str, Any]) -> bool:
if not session.get("docusign_access_token"):
return False
accounts = session.get("docusign_accounts") or []
return len(accounts) > 1 and not session.get("docusign_selected_account_id")
def current_account(session: dict[str, Any]) -> dict[str, Any]:
accounts = session.get("docusign_accounts") or []
if accounts:
selected_id = session.get("docusign_selected_account_id")
if not selected_id:
raise DocusignContextError(
"Select a DocuSign account before continuing.",
status_code=409,
code="account_selection_required",
)
selected = find_account(session, selected_id)
if not selected:
raise DocusignContextError(
"Selected DocuSign account is no longer available.",
status_code=409,
code="account_selection_required",
)
return selected
# Fallback for legacy/single-account env-based behavior.
if settings.docusign_account_id and settings.docusign_base_url:
return {
"account_id": settings.docusign_account_id,
"account_name": settings.docusign_account_id,
"base_url": settings.docusign_base_url.rstrip("/"),
"base_uri": settings.docusign_base_url.rstrip("/").removesuffix("/restapi"),
"is_default": True,
}
raise DocusignContextError(
"No DocuSign account is configured for this session.",
status_code=409,
code="account_selection_required",
)

View File

@ -21,7 +21,16 @@ from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from web.config import settings from web.config import settings
from web.session import get_session, get_session_id, save_session, session_public_view from web.docusign_context import (
DocusignContextError,
account_picker_required,
clear_selected_account,
current_account,
fetch_userinfo,
merge_userinfo,
select_account,
)
from web.session import get_session, save_session, session_public_view
router = APIRouter() router = APIRouter()
@ -39,12 +48,19 @@ _ADOBE_TOKEN_URL = "https://api.eu2.adobesign.com/oauth/v2/token"
def auth_status(request: Request): def auth_status(request: Request):
"""Returns which platforms the current session is connected to.""" """Returns which platforms the current session is connected to."""
session = get_session(request) session = get_session(request)
docusign_account = None
try:
docusign_account = current_account(session) if session.get("docusign_access_token") else None
except DocusignContextError:
docusign_account = None
return { return {
**session_public_view(session), **session_public_view(session),
"adobe_label": "Adobe Sign", "adobe_label": "Adobe Sign",
"docusign_label": session.get("docusign_user_name") or "Docusign", "docusign_label": session.get("docusign_user_name") or "Docusign",
"docusign_account_id": settings.docusign_account_id, "docusign_account_id": (docusign_account or {}).get("account_id"),
"base_url": settings.docusign_base_url, "docusign_account_name": (docusign_account or {}).get("account_name"),
"base_url": (docusign_account or {}).get("base_url", settings.docusign_base_url),
"docusign_account_selection_required": account_picker_required(session),
} }
@ -72,6 +88,10 @@ class AdobeExchangeRequest(BaseModel):
redirect_url: str redirect_url: str
class DocusignAccountSelectRequest(BaseModel):
account_id: str
@router.post("/adobe/exchange") @router.post("/adobe/exchange")
async def adobe_exchange(body: AdobeExchangeRequest, request: Request): async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
""" """
@ -175,7 +195,7 @@ def adobe_disconnect(request: Request):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/docusign/connect") @router.get("/docusign/connect")
def docusign_connect(request: Request): async def docusign_connect(request: Request):
""" """
Obtain a DocuSign access token from the current browser session. Obtain a DocuSign access token from the current browser session.
If the session has not been authorized yet, return an authorization URL so If the session has not been authorized yet, return an authorization URL so
@ -218,10 +238,18 @@ def docusign_connect(request: Request):
return response return response
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
if not session.get("docusign_accounts"):
try:
session = merge_userinfo(session, await fetch_userinfo(token))
except DocusignContextError as e:
return JSONResponse({"error": str(e)}, status_code=e.status_code)
session["docusign_access_token"] = token session["docusign_access_token"] = token
session["docusign_auth_mode"] = "session_oauth" session["docusign_auth_mode"] = "session_oauth"
response = JSONResponse({
response = JSONResponse({"connected": True}) "connected": True,
"account_selection_required": account_picker_required(session),
})
save_session(response, session) save_session(response, session)
return response return response
@ -262,13 +290,46 @@ async def docusign_callback(request: Request, code: str = "", state: str = ""):
try: try:
token_data = exchange_code_for_token(code) token_data = exchange_code_for_token(code)
session = session_from_token_data(token_data, session) session = session_from_token_data(token_data, session)
session = merge_userinfo(session, await fetch_userinfo(session["docusign_access_token"]))
except Exception as e: except Exception as e:
return JSONResponse({"error": "token exchange failed", "detail": str(e)}, status_code=502) return JSONResponse({"error": "token exchange failed", "detail": str(e)}, status_code=502)
session.pop("docusign_oauth_state", None) session.pop("docusign_oauth_state", None)
session["docusign_auth_mode"] = "session_oauth" session["docusign_auth_mode"] = "session_oauth"
response = RedirectResponse("/") response = RedirectResponse("/#/settings" if account_picker_required(session) else "/")
save_session(response, session)
return response
@router.get("/docusign/accounts")
def docusign_accounts(request: Request):
session = get_session(request)
if not session.get("docusign_access_token"):
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
return {
"accounts": session.get("docusign_accounts", []),
"selected_account_id": session.get("docusign_selected_account_id"),
"selection_required": account_picker_required(session),
}
@router.post("/docusign/account-select")
def docusign_account_select(body: DocusignAccountSelectRequest, request: Request):
session = get_session(request)
if not session.get("docusign_access_token"):
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
try:
session = select_account(session, body.account_id)
except DocusignContextError as e:
return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code)
response = JSONResponse(
{
"selected_account_id": session.get("docusign_selected_account_id"),
"selected_account_name": session.get("docusign_selected_account_name"),
}
)
save_session(response, session) save_session(response, session)
return response return response
@ -282,6 +343,9 @@ def docusign_disconnect(request: Request):
session.pop("docusign_oauth_state", None) session.pop("docusign_oauth_state", None)
session.pop("docusign_user_name", None) session.pop("docusign_user_name", None)
session.pop("docusign_user_email", None) session.pop("docusign_user_email", None)
session.pop("docusign_accounts", None)
session.pop("docusign_accounts_count", None)
clear_selected_account(session)
session["docusign_auth_mode"] = "disconnected" session["docusign_auth_mode"] = "disconnected"
response = JSONResponse({"disconnected": "docusign"}) response = JSONResponse({"disconnected": "docusign"})
save_session(response, session) save_session(response, session)

View File

@ -24,6 +24,7 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from web.config import settings from web.config import settings
from web.docusign_context import DocusignContextError, current_account
from web.session import get_session from web.session import get_session
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
@ -157,6 +158,8 @@ async def _migrate_one(
adobe_id: str, adobe_id: str,
adobe_access_token: str, adobe_access_token: str,
docusign_access_token: str, docusign_access_token: str,
docusign_account_id: str,
docusign_base_url: str,
options: MigrationOptions, options: MigrationOptions,
) -> dict: ) -> dict:
"""Run the full pipeline for one Adobe template. Returns a result record.""" """Run the full pipeline for one Adobe template. Returns a result record."""
@ -271,7 +274,7 @@ async def _migrate_one(
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "application/json", "Accept": "application/json",
} }
list_url = f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/templates" list_url = f"{docusign_base_url}/v2.1/accounts/{docusign_account_id}/templates"
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
# Duplicate detection # Duplicate detection
@ -351,6 +354,10 @@ async def run_migration(body: MigrateRequest, request: Request):
return JSONResponse({"error": "not authenticated to Adobe Sign"}, status_code=401) return JSONResponse({"error": "not authenticated to Adobe Sign"}, status_code=401)
if not session.get("docusign_access_token"): if not session.get("docusign_access_token"):
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401) return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
try:
account = current_account(session)
except DocusignContextError as e:
return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code)
ids = body.resolved_ids() ids = body.resolved_ids()
if not ids: if not ids:
@ -362,6 +369,8 @@ async def run_migration(body: MigrateRequest, request: Request):
aid, aid,
session["adobe_access_token"], session["adobe_access_token"],
session["docusign_access_token"], session["docusign_access_token"],
account["account_id"],
account["base_url"],
body.options, body.options,
) )
for aid in ids for aid in ids
@ -393,6 +402,8 @@ async def _run_batch_job(
ids: List[str], ids: List[str],
adobe_token: str, adobe_token: str,
ds_token: str, ds_token: str,
ds_account_id: str,
ds_base_url: str,
options: MigrationOptions, options: MigrationOptions,
) -> None: ) -> None:
"""Background coroutine that processes a batch job and updates _batch_jobs.""" """Background coroutine that processes a batch job and updates _batch_jobs."""
@ -402,11 +413,11 @@ async def _run_batch_job(
for i, adobe_id in enumerate(ids): for i, adobe_id in enumerate(ids):
job["progress"] = {"completed": i, "total": len(ids), "current_id": adobe_id} job["progress"] = {"completed": i, "total": len(ids), "current_id": adobe_id}
result = await _migrate_one(adobe_id, adobe_token, ds_token, options) result = await _migrate_one(adobe_id, adobe_token, ds_token, ds_account_id, ds_base_url, options)
# Retry once on transient failures (network errors, not validation blockers) # Retry once on transient failures (network errors, not validation blockers)
if result["status"] == "failed" and "upload failed" in (result.get("error") or ""): if result["status"] == "failed" and "upload failed" in (result.get("error") or ""):
result = await _migrate_one(adobe_id, adobe_token, ds_token, options) result = await _migrate_one(adobe_id, adobe_token, ds_token, ds_account_id, ds_base_url, options)
if result["status"] != "failed": if result["status"] != "failed":
result["retried"] = True result["retried"] = True
@ -445,6 +456,10 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
return JSONResponse({"error": "not authenticated to Adobe Sign"}, status_code=401) return JSONResponse({"error": "not authenticated to Adobe Sign"}, status_code=401)
if not session.get("docusign_access_token"): if not session.get("docusign_access_token"):
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401) return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
try:
account = current_account(session)
except DocusignContextError as e:
return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code)
ids = body.resolved_ids() ids = body.resolved_ids()
if not ids: if not ids:
@ -468,6 +483,8 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
job_id, session_scope, ids, job_id, session_scope, ids,
session["adobe_access_token"], session["adobe_access_token"],
session["docusign_access_token"], session["docusign_access_token"],
account["account_id"],
account["base_url"],
body.options, body.options,
) )
) )

View File

@ -14,6 +14,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from web.config import settings from web.config import settings
from web.docusign_context import DocusignContextError, current_account
from web.session import get_session from web.session import get_session
router = APIRouter() router = APIRouter()
@ -28,6 +29,10 @@ def _require_adobe(session: dict) -> Optional[JSONResponse]:
def _require_docusign(session: dict) -> Optional[JSONResponse]: def _require_docusign(session: dict) -> Optional[JSONResponse]:
if not session.get("docusign_access_token"): if not session.get("docusign_access_token"):
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401) return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
try:
current_account(session)
except DocusignContextError as e:
return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code)
return None return None
@ -69,10 +74,11 @@ async def list_docusign_templates(request: Request):
err = _require_docusign(session) err = _require_docusign(session)
if err: if err:
return err return err
account = current_account(session)
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get( resp = await client.get(
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/templates", f"{account['base_url']}/v2.1/accounts/{account['account_id']}/templates",
headers={"Authorization": f"Bearer {session['docusign_access_token']}"}, headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
params={"count": 100}, params={"count": 100},
) )
@ -107,6 +113,7 @@ async def template_status(request: Request):
err = _require_adobe(session) or _require_docusign(session) err = _require_adobe(session) or _require_docusign(session)
if err: if err:
return err return err
account = current_account(session)
# Fetch both lists concurrently # Fetch both lists concurrently
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@ -117,7 +124,7 @@ async def template_status(request: Request):
params={"pageSize": 100}, params={"pageSize": 100},
), ),
client.get( client.get(
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/templates", f"{account['base_url']}/v2.1/accounts/{account['account_id']}/templates",
headers={"Authorization": f"Bearer {session['docusign_access_token']}"}, headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
params={"count": 100}, params={"count": 100},
), ),

View File

@ -12,7 +12,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from web.config import settings from web.docusign_context import DocusignContextError, current_account
from web.session import get_session from web.session import get_session
router = APIRouter() router = APIRouter()
@ -31,6 +31,10 @@ class VoidRequest(BaseModel):
def _require_docusign(session: dict) -> Optional[JSONResponse]: def _require_docusign(session: dict) -> Optional[JSONResponse]:
if not session.get("docusign_access_token"): if not session.get("docusign_access_token"):
return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401) return JSONResponse({"error": "not authenticated to DocuSign"}, status_code=401)
try:
current_account(session)
except DocusignContextError as e:
return JSONResponse({"error": str(e), "code": e.code}, status_code=e.status_code)
return None return None
@ -41,12 +45,13 @@ async def send_test_envelope(body: SendRequest, request: Request):
err = _require_docusign(session) err = _require_docusign(session)
if err: if err:
return err return err
account = current_account(session)
headers = { headers = {
"Authorization": f"Bearer {session['docusign_access_token']}", "Authorization": f"Bearer {session['docusign_access_token']}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
base = f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}" base = f"{account['base_url']}/v2.1/accounts/{account['account_id']}"
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
# Fetch template to discover actual role names # Fetch template to discover actual role names
@ -97,10 +102,11 @@ async def envelope_status(envelope_id: str, request: Request):
err = _require_docusign(session) err = _require_docusign(session)
if err: if err:
return err return err
account = current_account(session)
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get( resp = await client.get(
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}", f"{account['base_url']}/v2.1/accounts/{account['account_id']}/envelopes/{envelope_id}",
headers={"Authorization": f"Bearer {session['docusign_access_token']}"}, headers={"Authorization": f"Bearer {session['docusign_access_token']}"},
) )
@ -126,10 +132,11 @@ async def void_envelope(envelope_id: str, body: VoidRequest, request: Request):
err = _require_docusign(session) err = _require_docusign(session)
if err: if err:
return err return err
account = current_account(session)
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.put( resp = await client.put(
f"{settings.docusign_base_url}/v2.1/accounts/{settings.docusign_account_id}/envelopes/{envelope_id}", f"{account['base_url']}/v2.1/accounts/{account['account_id']}/envelopes/{envelope_id}",
headers={ headers={
"Authorization": f"Bearer {session['docusign_access_token']}", "Authorization": f"Bearer {session['docusign_access_token']}",
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@ -158,4 +158,7 @@ def session_public_view(session: dict[str, Any]) -> dict[str, Any]:
"docusign_auth_mode": session.get("docusign_auth_mode", "disconnected"), "docusign_auth_mode": session.get("docusign_auth_mode", "disconnected"),
"docusign_user_name": session.get("docusign_user_name"), "docusign_user_name": session.get("docusign_user_name"),
"docusign_user_email": session.get("docusign_user_email"), "docusign_user_email": session.get("docusign_user_email"),
"docusign_selected_account_id": session.get("docusign_selected_account_id"),
"docusign_selected_account_name": session.get("docusign_selected_account_name"),
"docusign_accounts_count": session.get("docusign_accounts_count", 0),
} }

View File

@ -32,6 +32,7 @@
} }
.modal-box.modal-lg { width: min(720px, 94vw); } .modal-box.modal-lg { width: min(720px, 94vw); }
.modal-box.modal-box-wide { width: min(900px, 96vw); }
.modal-box.modal-sm { width: min(380px, 94vw); } .modal-box.modal-sm { width: min(380px, 94vw); }
/* ── Modal sections ── */ /* ── Modal sections ── */
@ -190,3 +191,52 @@
font-weight: 700; font-weight: 700;
margin-bottom: 10px; margin-bottom: 10px;
} }
/* ── DocuSign account picker ── */
.docusign-account-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 420px;
overflow-y: auto;
}
.docusign-account-item {
width: 100%;
border: 1px solid var(--border);
background: var(--card-bg);
border-radius: var(--radius-sm);
padding: 12px 14px;
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
align-items: center;
text-align: left;
cursor: pointer;
}
.docusign-account-item:hover {
background: var(--ecru);
}
.docusign-account-item.selected {
border-color: var(--cobalt);
background: var(--cobalt-light);
}
.docusign-account-name {
font-weight: 700;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.docusign-account-meta {
font-size: var(--font-size-sm);
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 768px) {
.docusign-account-item {
grid-template-columns: 1fr;
}
}

View File

@ -38,6 +38,12 @@ export const api = {
connectDocusign() { connectDocusign() {
return GET('/api/auth/docusign/connect'); return GET('/api/auth/docusign/connect');
}, },
docusignAccounts() {
return GET('/api/auth/docusign/accounts');
},
selectDocusignAccount(accountId) {
return POST('/api/auth/docusign/account-select', { account_id: accountId });
},
disconnect(platform) { disconnect(platform) {
return GET(`/api/auth/${platform}/disconnect`); return GET(`/api/auth/${platform}/disconnect`);
}, },

View File

@ -1,4 +1,4 @@
// Auth: connect/disconnect Adobe Sign and Docusign, auth status chips // Auth: connect/disconnect Adobe Sign and Docusign, account picker, auth chips
import { api } from './api.js'; import { api } from './api.js';
import { state, setState } from './state.js'; import { state, setState } from './state.js';
@ -14,18 +14,30 @@ export async function refreshAuth() {
docusign: !!data.docusign, docusign: !!data.docusign,
adobeLabel: data.adobe_label || 'Adobe Sign', adobeLabel: data.adobe_label || 'Adobe Sign',
docusignLabel: data.docusign_label || 'Docusign', docusignLabel: data.docusign_label || 'Docusign',
docusignAccountId: data.docusign_account_id || null,
docusignAccountName: data.docusign_account_name || null,
docusignAccountsCount: data.docusign_accounts_count || 0,
docusignAccountSelectionRequired: !!data.docusign_account_selection_required,
}); });
} catch (e) { } catch (e) {
console.warn('Auth status failed:', e.message); console.warn('Auth status failed:', e.message);
} }
renderAuthChips(); renderAuthChips();
if (state.auth.docusign && state.auth.docusignAccountSelectionRequired) {
showDocusignAccountPicker();
}
} }
// ── Render connection pills in top bar ───────────────────────────────────── // ── Render connection pills in top bar ─────────────────────────────────────
export function renderAuthChips() { export function renderAuthChips() {
renderChip('chip-adobe', state.auth.adobe, 'Adobe Sign', onClickAdobe); renderChip('chip-adobe', state.auth.adobe, 'Adobe Sign', onClickAdobe);
renderChip('chip-docusign', state.auth.docusign, 'Docusign', onClickDocusign); renderChip(
'chip-docusign',
state.auth.docusign,
state.auth.docusignAccountName || 'Docusign',
onClickDocusign
);
} }
function renderChip(id, connected, label, onClick) { function renderChip(id, connected, label, onClick) {
@ -56,10 +68,23 @@ async function onClickDocusign() {
export async function disconnectPlatform(platform, opts = {}) { export async function disconnectPlatform(platform, opts = {}) {
const { silent = false, skipRefresh = false } = opts; const { silent = false, skipRefresh = false } = opts;
closeAuthMenu();
closeDocusignAccountPicker();
setChipConnecting(platform === 'adobe' ? 'chip-adobe' : 'chip-docusign'); setChipConnecting(platform === 'adobe' ? 'chip-adobe' : 'chip-docusign');
try { try {
await api.auth.disconnect(platform); await api.auth.disconnect(platform);
setState('auth', { ...state.auth, [platform]: false }); if (platform === 'docusign') {
setState('auth', {
...state.auth,
docusign: false,
docusignAccountId: null,
docusignAccountName: null,
docusignAccountsCount: 0,
docusignAccountSelectionRequired: false,
});
} else {
setState('auth', { ...state.auth, adobe: false });
}
renderAuthChips(); renderAuthChips();
if (!skipRefresh) { if (!skipRefresh) {
const { refreshTemplates } = await import('./templates.js'); const { refreshTemplates } = await import('./templates.js');
@ -71,27 +96,20 @@ export async function disconnectPlatform(platform, opts = {}) {
} catch (e) { } catch (e) {
console.error('Disconnect failed:', e.message); console.error('Disconnect failed:', e.message);
renderAuthChips(); renderAuthChips();
if (!silent) { if (!silent) showToast(`Disconnect failed: ${e.message}`, 'error');
showToast(`Disconnect failed: ${e.message}`, 'error');
}
} }
} }
export async function switchAccount(platform) { export async function switchAccount(platform) {
closeAuthMenu(); closeAuthMenu();
await disconnectPlatform(platform, { silent: true, skipRefresh: true });
if (platform === 'docusign') { if (platform === 'docusign') {
showToast('Starting a fresh Docusign authorization. If Docusign signs you in automatically, sign out there and try again to choose a different account.', 'info'); await showDocusignAccountPicker({ forceRefresh: true });
window.location.href = '/api/auth/docusign/start';
return; return;
} }
await disconnectPlatform(platform, { silent: true, skipRefresh: true });
if (platform === 'adobe') {
showToast('Adobe Sign disconnected. Reconnect to continue.', 'info'); showToast('Adobe Sign disconnected. Reconnect to continue.', 'info');
await connectAdobeEnv(); await connectAdobeEnv();
} }
}
async function connectAdobeEnv() { async function connectAdobeEnv() {
closeAuthMenu(); closeAuthMenu();
@ -122,10 +140,11 @@ async function connectDocusign() {
try { try {
const data = await api.auth.connectDocusign(); const data = await api.auth.connectDocusign();
if (data.connected) { if (data.connected) {
setState('auth', { ...state.auth, docusign: true }); await refreshAuth();
renderAuthChips(); if (!data.account_selection_required) {
const { refreshTemplates } = await import('./templates.js'); const { refreshTemplates } = await import('./templates.js');
refreshTemplates(); refreshTemplates();
}
} else if (data.authorization_required && data.authorization_url) { } else if (data.authorization_required && data.authorization_url) {
window.location.href = data.authorization_url; window.location.href = data.authorization_url;
} else { } else {
@ -145,6 +164,8 @@ function setChipConnecting(id) {
el.innerHTML = `<span class="conn-dot"></span><span class="spinner spinner-sm"></span>`; el.innerHTML = `<span class="conn-dot"></span><span class="spinner spinner-sm"></span>`;
} }
// ── Top-bar menu ───────────────────────────────────────────────────────────
function closeAuthMenu() { function closeAuthMenu() {
document.getElementById('auth-chip-menu')?.remove(); document.getElementById('auth-chip-menu')?.remove();
document.removeEventListener('click', onDocumentClickCloseMenu, true); document.removeEventListener('click', onDocumentClickCloseMenu, true);
@ -186,7 +207,7 @@ function showAuthMenu(platform, anchorId) {
const accountLabel = platform === 'docusign' ? 'Docusign' : 'Adobe Sign'; const accountLabel = platform === 'docusign' ? 'Docusign' : 'Adobe Sign';
const switchLabel = platform === 'docusign' ? 'Switch Account' : 'Reconnect'; const switchLabel = platform === 'docusign' ? 'Switch Account' : 'Reconnect';
const switchHelp = platform === 'docusign' const switchHelp = platform === 'docusign'
? 'Clear this browser session and start a fresh login flow.' ? 'Pick a different DocuSign account from your account list.'
: 'Disconnect and reconnect Adobe Sign.'; : 'Disconnect and reconnect Adobe Sign.';
menu.innerHTML = ` menu.innerHTML = `
@ -214,6 +235,121 @@ function showAuthMenu(platform, anchorId) {
document.addEventListener('keydown', onEscapeCloseMenu, true); document.addEventListener('keydown', onEscapeCloseMenu, true);
} }
// ── DocuSign account picker ────────────────────────────────────────────────
export async function showDocusignAccountPicker(opts = {}) {
const { forceRefresh = false } = opts;
if (!forceRefresh && document.getElementById('docusign-account-dialog')) return;
let data;
try {
data = await api.auth.docusignAccounts();
} catch (e) {
showToast('Failed to load DocuSign accounts: ' + e.message, 'error');
return;
}
const accounts = [...(data.accounts || [])].sort((a, b) => {
const nameCmp = (a.account_name || '').localeCompare(b.account_name || '', undefined, { sensitivity: 'base' });
return nameCmp || (a.account_id || '').localeCompare(b.account_id || '', undefined, { sensitivity: 'base' });
});
if (!accounts.length) {
showToast('No DocuSign accounts were returned for this user.', 'error');
return;
}
if (accounts.length === 1) {
await selectDocusignAccount(accounts[0].account_id);
return;
}
closeDocusignAccountPicker();
const dialog = document.createElement('div');
dialog.id = 'docusign-account-dialog';
dialog.innerHTML = `
<div class="modal-backdrop"></div>
<div class="modal-box modal-box-wide">
<div class="modal-header">
<span class="modal-title">Choose DocuSign Account</span>
<button class="btn btn-ghost btn-icon" id="docusign-account-close"></button>
</div>
<div class="modal-body">
<div style="display:flex;gap:12px;align-items:center;justify-content:space-between;margin-bottom:14px;flex-wrap:wrap">
<div style="font-size:13px;color:var(--text-muted)">
${accounts.length} account${accounts.length === 1 ? '' : 's'} found. Choose the account this session should use.
</div>
<input type="text" id="docusign-account-search" class="form-input" placeholder="Search accounts..." style="max-width:320px" />
</div>
<div id="docusign-account-error" style="color:var(--error);font-size:12px;min-height:18px;margin-bottom:8px"></div>
<div id="docusign-account-list" class="docusign-account-list"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="docusign-account-cancel">Close</button>
</div>
</div>
`;
document.body.appendChild(dialog);
const listEl = document.getElementById('docusign-account-list');
const searchEl = document.getElementById('docusign-account-search');
const errorEl = document.getElementById('docusign-account-error');
const renderList = () => {
const query = (searchEl?.value || '').trim().toLowerCase();
const filtered = accounts.filter(acc => {
const haystack = `${acc.account_name || ''} ${acc.account_id || ''} ${acc.organization_name || ''}`.toLowerCase();
return !query || haystack.includes(query);
});
if (!filtered.length) {
listEl.innerHTML = `<div class="empty-state" style="padding:24px 12px"><div class="empty-state-title">No matching accounts</div></div>`;
return;
}
listEl.innerHTML = filtered.map(acc => `
<button class="docusign-account-item ${data.selected_account_id === acc.account_id ? 'selected' : ''}" data-account-id="${escHtml(acc.account_id)}">
<span class="docusign-account-name">${escHtml(acc.account_name || acc.account_id)}</span>
<span class="docusign-account-meta mono">${escHtml(acc.account_id)}</span>
<span class="docusign-account-meta">${escHtml(acc.organization_name || '')}</span>
</button>
`).join('');
listEl.querySelectorAll('.docusign-account-item').forEach(btn => {
btn.addEventListener('click', async () => {
errorEl.textContent = '';
await selectDocusignAccount(btn.dataset.accountId, errorEl);
});
});
};
searchEl?.addEventListener('input', renderList);
document.getElementById('docusign-account-close')?.addEventListener('click', closeDocusignAccountPicker);
document.getElementById('docusign-account-cancel')?.addEventListener('click', closeDocusignAccountPicker);
renderList();
}
function closeDocusignAccountPicker() {
document.getElementById('docusign-account-dialog')?.remove();
}
async function selectDocusignAccount(accountId, errorEl = null) {
try {
await api.auth.selectDocusignAccount(accountId);
closeDocusignAccountPicker();
await refreshAuth();
const { refreshTemplates } = await import('./templates.js');
refreshTemplates();
showToast('DocuSign account selected.', 'success');
} catch (e) {
if (errorEl) {
errorEl.textContent = e.data?.error || e.message || 'Failed to select account.';
} else {
showToast('Failed to select account: ' + e.message, 'error');
}
}
}
// ── Adobe OAuth dialog (manual redirect URL paste) ───────────────────────── // ── Adobe OAuth dialog (manual redirect URL paste) ─────────────────────────
async function showAdobeOAuthDialog() { async function showAdobeOAuthDialog() {
@ -268,7 +404,7 @@ async function submitAdobeCode(dialog) {
errorEl.textContent = ''; errorEl.textContent = '';
try { try {
const data = await api.auth.exchangeAdobe(url); await api.auth.exchangeAdobe(url);
dialog.remove(); dialog.remove();
setState('auth', { ...state.auth, adobe: true }); setState('auth', { ...state.auth, adobe: true });
renderAuthChips(); renderAuthChips();
@ -297,9 +433,9 @@ export function showToast(message, type = 'info') {
toast.style.cssText = ` toast.style.cssText = `
padding:10px 16px;border-radius:6px;font-size:13px;font-weight:500; padding:10px 16px;border-radius:6px;font-size:13px;font-weight:500;
background:${colors[type] || colors.info};border:1px solid ${borders[type] || borders.info}; background:${colors[type] || colors.info};border:1px solid ${borders[type] || borders.info};
box-shadow:var(--shadow-md);max-width:360px;animation:fadeIn 0.2s ease; box-shadow:var(--shadow-md);max-width:420px;animation:fadeIn 0.2s ease;
`; `;
toast.textContent = message; toast.textContent = message;
container.appendChild(toast); container.appendChild(toast);
setTimeout(() => toast.remove(), 4000); setTimeout(() => toast.remove(), 4500);
} }

View File

@ -1,9 +1,8 @@
// Settings view — verification defaults, migration defaults, connection info // Settings view — verification defaults, migration defaults, connection info
import { api } from './api.js'; import { api } from './api.js';
import { state } from './state.js';
import { escHtml } from './utils.js'; import { escHtml } from './utils.js';
import { disconnectPlatform, switchAccount } from './auth.js'; import { disconnectPlatform, showDocusignAccountPicker, switchAccount } from './auth.js';
const SETTINGS_KEY = 'migrator_settings'; const SETTINGS_KEY = 'migrator_settings';
@ -163,9 +162,9 @@ async function _loadConnInfo() {
</div> </div>
<div class="conn-info-row"> <div class="conn-info-row">
<span class="conn-info-label">Docusign</span> <span class="conn-info-label">Docusign</span>
<span class="conn-info-value">${data.docusign ? 'Connected' : 'Not connected'}</span> <span class="conn-info-value">${data.docusign ? (data.docusign_account_selection_required ? 'Connected — account selection required' : 'Connected') : 'Not connected'}</span>
<span class="conn-info-status"> <span class="conn-info-status">
<span class="badge ${data.docusign ? 'badge-green' : 'badge-gray'}">${data.docusign ? '● Connected' : '○ Disconnected'}</span> <span class="badge ${data.docusign ? (data.docusign_account_selection_required ? 'badge-amber' : 'badge-green') : 'badge-gray'}">${data.docusign ? (data.docusign_account_selection_required ? '● Choose account' : '● Connected') : '○ Disconnected'}</span>
</span> </span>
</div> </div>
<div class="conn-info-row conn-info-actions"> <div class="conn-info-row conn-info-actions">
@ -180,6 +179,7 @@ async function _loadConnInfo() {
<span class="conn-info-value" style="display:flex;gap:8px;flex-wrap:wrap"> <span class="conn-info-value" style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn btn-secondary" id="btn-disconnect-docusign" ${data.docusign ? '' : 'disabled'}>Disconnect Docusign</button> <button class="btn btn-secondary" id="btn-disconnect-docusign" ${data.docusign ? '' : 'disabled'}>Disconnect Docusign</button>
<button class="btn btn-primary" id="btn-switch-docusign" ${data.docusign ? '' : 'disabled'}>Switch Docusign Account</button> <button class="btn btn-primary" id="btn-switch-docusign" ${data.docusign ? '' : 'disabled'}>Switch Docusign Account</button>
<button class="btn btn-secondary" id="btn-choose-docusign-account" ${data.docusign ? '' : 'disabled'}>Choose Account</button>
</span> </span>
<span class="conn-info-status"></span> <span class="conn-info-status"></span>
</div> </div>
@ -200,7 +200,7 @@ async function _loadConnInfo() {
</div> </div>
<div class="conn-info-row"> <div class="conn-info-row">
<span class="conn-info-label">Switch Account Note</span> <span class="conn-info-label">Switch Account Note</span>
<span class="conn-info-value">Use <strong>Switch Docusign Account</strong> to clear this browser session and start a fresh login flow. If Docusign signs you straight back in, sign out of Docusign in that browser first.</span> <span class="conn-info-value">Use <strong>Choose Account</strong> or <strong>Switch Docusign Account</strong> to select from the DocuSign accounts available to this login. The picker is sorted alphabetically and supports search.</span>
<span class="conn-info-status"></span> <span class="conn-info-status"></span>
</div> </div>
<div class="conn-info-row"> <div class="conn-info-row">
@ -230,4 +230,11 @@ async function _loadConnInfo() {
document.getElementById('btn-switch-docusign')?.addEventListener('click', async () => { document.getElementById('btn-switch-docusign')?.addEventListener('click', async () => {
await switchAccount('docusign'); await switchAccount('docusign');
}); });
document.getElementById('btn-choose-docusign-account')?.addEventListener('click', async () => {
await showDocusignAccountPicker({ forceRefresh: true });
});
if (data.docusign && data.docusign_account_selection_required) {
await showDocusignAccountPicker();
}
} }

View File

@ -9,6 +9,10 @@ export const state = {
docusign: false, docusign: false,
adobeLabel: 'Adobe Sign', adobeLabel: 'Adobe Sign',
docusignLabel: 'Docusign', docusignLabel: 'Docusign',
docusignAccountId: null,
docusignAccountName: null,
docusignAccountsCount: 0,
docusignAccountSelectionRequired: false,
}, },
templates: [], // [{ adobe_id, name, status, blockers, warnings, ... }] templates: [], // [{ adobe_id, name, status, blockers, warnings, ... }]
selectedIds: new Set(), selectedIds: new Set(),