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 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
- 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
### Navigation
@ -151,7 +152,8 @@ Important behavior:
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.
- 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:
- **Ready** (green) — no issues, safe to migrate
- **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)
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)
def temp_session_store(tmp_path, monkeypatch):
import web.config as cfg
@ -107,20 +128,23 @@ def test_adobe_exchange_rejects_missing_code():
def test_docusign_connect_stores_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"})
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})
assert resp.status_code == 200
assert resp.json()["connected"] is True
assert resp.json()["account_selection_required"] is True
session_cookie = resp.cookies.get("migrator_session")
assert session_cookie is not None
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
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():
@ -149,13 +173,13 @@ def test_docusign_callback_requires_matching_state():
def test_docusign_callback_stores_per_session_tokens():
"""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"})
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},
@ -167,11 +191,12 @@ def test_docusign_callback_stores_per_session_tokens():
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
assert status_resp.json()["docusign"] is True
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():
"""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_b = create_test_session({})
@ -179,7 +204,7 @@ def test_docusign_sessions_are_isolated():
with patch(
"docusign_auth.exchange_code_for_token",
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:
callback_resp = client_a.get(
"/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
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
def test_disconnect_clears_token():
"""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 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()
@ -39,12 +48,19 @@ _ADOBE_TOKEN_URL = "https://api.eu2.adobesign.com/oauth/v2/token"
def auth_status(request: Request):
"""Returns which platforms the current session is connected to."""
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 {
**session_public_view(session),
"adobe_label": "Adobe Sign",
"docusign_label": session.get("docusign_user_name") or "Docusign",
"docusign_account_id": settings.docusign_account_id,
"base_url": settings.docusign_base_url,
"docusign_account_id": (docusign_account or {}).get("account_id"),
"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
class DocusignAccountSelectRequest(BaseModel):
account_id: str
@router.post("/adobe/exchange")
async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
"""
@ -175,7 +195,7 @@ def adobe_disconnect(request: Request):
# ---------------------------------------------------------------------------
@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.
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 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_auth_mode"] = "session_oauth"
response = JSONResponse({"connected": True})
response = JSONResponse({
"connected": True,
"account_selection_required": account_picker_required(session),
})
save_session(response, session)
return response
@ -262,13 +290,46 @@ async def docusign_callback(request: Request, code: str = "", state: str = ""):
try:
token_data = exchange_code_for_token(code)
session = session_from_token_data(token_data, session)
session = merge_userinfo(session, await fetch_userinfo(session["docusign_access_token"]))
except Exception as e:
return JSONResponse({"error": "token exchange failed", "detail": str(e)}, status_code=502)
session.pop("docusign_oauth_state", None)
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)
return response
@ -282,6 +343,9 @@ def docusign_disconnect(request: Request):
session.pop("docusign_oauth_state", None)
session.pop("docusign_user_name", 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"
response = JSONResponse({"disconnected": "docusign"})
save_session(response, session)

View File

@ -24,6 +24,7 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel
from web.config import settings
from web.docusign_context import DocusignContextError, current_account
from web.session import get_session
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
@ -157,6 +158,8 @@ async def _migrate_one(
adobe_id: str,
adobe_access_token: str,
docusign_access_token: str,
docusign_account_id: str,
docusign_base_url: str,
options: MigrationOptions,
) -> dict:
"""Run the full pipeline for one Adobe template. Returns a result record."""
@ -271,7 +274,7 @@ async def _migrate_one(
"Content-Type": "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:
# 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)
if not session.get("docusign_access_token"):
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()
if not ids:
@ -362,6 +369,8 @@ async def run_migration(body: MigrateRequest, request: Request):
aid,
session["adobe_access_token"],
session["docusign_access_token"],
account["account_id"],
account["base_url"],
body.options,
)
for aid in ids
@ -393,6 +402,8 @@ async def _run_batch_job(
ids: List[str],
adobe_token: str,
ds_token: str,
ds_account_id: str,
ds_base_url: str,
options: MigrationOptions,
) -> None:
"""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):
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)
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":
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)
if not session.get("docusign_access_token"):
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()
if not ids:
@ -468,6 +483,8 @@ async def run_batch_migration(body: MigrateRequest, request: Request):
job_id, session_scope, ids,
session["adobe_access_token"],
session["docusign_access_token"],
account["account_id"],
account["base_url"],
body.options,
)
)

View File

@ -14,6 +14,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from web.config import settings
from web.docusign_context import DocusignContextError, current_account
from web.session import get_session
router = APIRouter()
@ -28,6 +29,10 @@ def _require_adobe(session: dict) -> Optional[JSONResponse]:
def _require_docusign(session: dict) -> Optional[JSONResponse]:
if not session.get("docusign_access_token"):
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
@ -69,10 +74,11 @@ async def list_docusign_templates(request: Request):
err = _require_docusign(session)
if err:
return err
account = current_account(session)
async with httpx.AsyncClient() as client:
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']}"},
params={"count": 100},
)
@ -107,6 +113,7 @@ async def template_status(request: Request):
err = _require_adobe(session) or _require_docusign(session)
if err:
return err
account = current_account(session)
# Fetch both lists concurrently
async with httpx.AsyncClient() as client:
@ -117,7 +124,7 @@ async def template_status(request: Request):
params={"pageSize": 100},
),
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']}"},
params={"count": 100},
),

View File

@ -12,7 +12,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from web.config import settings
from web.docusign_context import DocusignContextError, current_account
from web.session import get_session
router = APIRouter()
@ -31,6 +31,10 @@ class VoidRequest(BaseModel):
def _require_docusign(session: dict) -> Optional[JSONResponse]:
if not session.get("docusign_access_token"):
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
@ -41,12 +45,13 @@ async def send_test_envelope(body: SendRequest, request: Request):
err = _require_docusign(session)
if err:
return err
account = current_account(session)
headers = {
"Authorization": f"Bearer {session['docusign_access_token']}",
"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:
# Fetch template to discover actual role names
@ -97,10 +102,11 @@ async def envelope_status(envelope_id: str, request: Request):
err = _require_docusign(session)
if err:
return err
account = current_account(session)
async with httpx.AsyncClient() as client:
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']}"},
)
@ -126,10 +132,11 @@ async def void_envelope(envelope_id: str, body: VoidRequest, request: Request):
err = _require_docusign(session)
if err:
return err
account = current_account(session)
async with httpx.AsyncClient() as client:
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={
"Authorization": f"Bearer {session['docusign_access_token']}",
"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_user_name": session.get("docusign_user_name"),
"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-box-wide { width: min(900px, 96vw); }
.modal-box.modal-sm { width: min(380px, 94vw); }
/* ── Modal sections ── */
@ -190,3 +191,52 @@
font-weight: 700;
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() {
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) {
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 { state, setState } from './state.js';
@ -14,18 +14,30 @@ export async function refreshAuth() {
docusign: !!data.docusign,
adobeLabel: data.adobe_label || 'Adobe Sign',
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) {
console.warn('Auth status failed:', e.message);
}
renderAuthChips();
if (state.auth.docusign && state.auth.docusignAccountSelectionRequired) {
showDocusignAccountPicker();
}
}
// ── Render connection pills in top bar ─────────────────────────────────────
export function renderAuthChips() {
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) {
@ -56,10 +68,23 @@ async function onClickDocusign() {
export async function disconnectPlatform(platform, opts = {}) {
const { silent = false, skipRefresh = false } = opts;
closeAuthMenu();
closeDocusignAccountPicker();
setChipConnecting(platform === 'adobe' ? 'chip-adobe' : 'chip-docusign');
try {
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();
if (!skipRefresh) {
const { refreshTemplates } = await import('./templates.js');
@ -71,27 +96,20 @@ export async function disconnectPlatform(platform, opts = {}) {
} catch (e) {
console.error('Disconnect failed:', e.message);
renderAuthChips();
if (!silent) {
showToast(`Disconnect failed: ${e.message}`, 'error');
}
if (!silent) showToast(`Disconnect failed: ${e.message}`, 'error');
}
}
export async function switchAccount(platform) {
closeAuthMenu();
await disconnectPlatform(platform, { silent: true, skipRefresh: true });
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');
window.location.href = '/api/auth/docusign/start';
await showDocusignAccountPicker({ forceRefresh: true });
return;
}
if (platform === 'adobe') {
await disconnectPlatform(platform, { silent: true, skipRefresh: true });
showToast('Adobe Sign disconnected. Reconnect to continue.', 'info');
await connectAdobeEnv();
}
}
async function connectAdobeEnv() {
closeAuthMenu();
@ -122,10 +140,11 @@ async function connectDocusign() {
try {
const data = await api.auth.connectDocusign();
if (data.connected) {
setState('auth', { ...state.auth, docusign: true });
renderAuthChips();
await refreshAuth();
if (!data.account_selection_required) {
const { refreshTemplates } = await import('./templates.js');
refreshTemplates();
}
} else if (data.authorization_required && data.authorization_url) {
window.location.href = data.authorization_url;
} else {
@ -145,6 +164,8 @@ function setChipConnecting(id) {
el.innerHTML = `<span class="conn-dot"></span><span class="spinner spinner-sm"></span>`;
}
// ── Top-bar menu ───────────────────────────────────────────────────────────
function closeAuthMenu() {
document.getElementById('auth-chip-menu')?.remove();
document.removeEventListener('click', onDocumentClickCloseMenu, true);
@ -186,7 +207,7 @@ function showAuthMenu(platform, anchorId) {
const accountLabel = platform === 'docusign' ? 'Docusign' : 'Adobe Sign';
const switchLabel = platform === 'docusign' ? 'Switch Account' : 'Reconnect';
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.';
menu.innerHTML = `
@ -214,6 +235,121 @@ function showAuthMenu(platform, anchorId) {
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) ─────────────────────────
async function showAdobeOAuthDialog() {
@ -268,7 +404,7 @@ async function submitAdobeCode(dialog) {
errorEl.textContent = '';
try {
const data = await api.auth.exchangeAdobe(url);
await api.auth.exchangeAdobe(url);
dialog.remove();
setState('auth', { ...state.auth, adobe: true });
renderAuthChips();
@ -297,9 +433,9 @@ export function showToast(message, type = 'info') {
toast.style.cssText = `
padding:10px 16px;border-radius:6px;font-size:13px;font-weight:500;
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;
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
import { api } from './api.js';
import { state } from './state.js';
import { escHtml } from './utils.js';
import { disconnectPlatform, switchAccount } from './auth.js';
import { disconnectPlatform, showDocusignAccountPicker, switchAccount } from './auth.js';
const SETTINGS_KEY = 'migrator_settings';
@ -163,9 +162,9 @@ async function _loadConnInfo() {
</div>
<div class="conn-info-row">
<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="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>
</div>
<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">
<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-secondary" id="btn-choose-docusign-account" ${data.docusign ? '' : 'disabled'}>Choose Account</button>
</span>
<span class="conn-info-status"></span>
</div>
@ -200,7 +200,7 @@ async function _loadConnInfo() {
</div>
<div class="conn-info-row">
<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>
</div>
<div class="conn-info-row">
@ -230,4 +230,11 @@ async function _loadConnInfo() {
document.getElementById('btn-switch-docusign')?.addEventListener('click', async () => {
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,
adobeLabel: 'Adobe Sign',
docusignLabel: 'Docusign',
docusignAccountId: null,
docusignAccountName: null,
docusignAccountsCount: 0,
docusignAccountSelectionRequired: false,
},
templates: [], // [{ adobe_id, name, status, blockers, warnings, ... }]
selectedIds: new Set(),