From 90113a6514b74ccafad0230d50b653bc7c866408 Mon Sep 17 00:00:00 2001 From: Paul Huliganga Date: Tue, 21 Apr 2026 23:06:48 -0400 Subject: [PATCH] Add DocuSign account picker --- README.md | 4 +- tests/test_api_auth.py | 71 ++++++++++++-- web/docusign_context.py | 160 +++++++++++++++++++++++++++++++ web/routers/auth.py | 78 +++++++++++++-- web/routers/migrate.py | 23 ++++- web/routers/templates.py | 11 ++- web/routers/verify.py | 15 ++- web/session.py | 3 + web/static/css/modals.css | 50 ++++++++++ web/static/js/api.js | 6 ++ web/static/js/auth.js | 194 ++++++++++++++++++++++++++++++++------ web/static/js/settings.js | 17 +++- web/static/js/state.js | 4 + 13 files changed, 579 insertions(+), 57 deletions(-) create mode 100644 web/docusign_context.py diff --git a/README.md b/README.md index df49f7d..4f16c5a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py index 29feacb..b675ac8 100644 --- a/tests/test_api_auth.py +++ b/tests/test_api_auth.py @@ -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.""" diff --git a/web/docusign_context.py b/web/docusign_context.py new file mode 100644 index 0000000..1f59fdb --- /dev/null +++ b/web/docusign_context.py @@ -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", + ) diff --git a/web/routers/auth.py b/web/routers/auth.py index ba1475c..30634e7 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -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) diff --git a/web/routers/migrate.py b/web/routers/migrate.py index 7be0b91..0169727 100644 --- a/web/routers/migrate.py +++ b/web/routers/migrate.py @@ -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, ) ) diff --git a/web/routers/templates.py b/web/routers/templates.py index 378bde8..eb2753f 100644 --- a/web/routers/templates.py +++ b/web/routers/templates.py @@ -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}, ), diff --git a/web/routers/verify.py b/web/routers/verify.py index 709a4f5..d4b9e9a 100644 --- a/web/routers/verify.py +++ b/web/routers/verify.py @@ -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", diff --git a/web/session.py b/web/session.py index f33f022..f1e9fea 100644 --- a/web/session.py +++ b/web/session.py @@ -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), } diff --git a/web/static/css/modals.css b/web/static/css/modals.css index 432a7bf..dd948af 100644 --- a/web/static/css/modals.css +++ b/web/static/css/modals.css @@ -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; + } +} diff --git a/web/static/js/api.js b/web/static/js/api.js index 45221e9..fe0659a 100644 --- a/web/static/js/api.js +++ b/web/static/js/api.js @@ -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`); }, diff --git a/web/static/js/auth.js b/web/static/js/auth.js index 16c9a6d..f992e16 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -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'; @@ -10,22 +10,34 @@ export async function refreshAuth() { try { const data = await api.auth.status(); setState('auth', { - adobe: !!data.adobe, + adobe: !!data.adobe, docusign: !!data.docusign, - adobeLabel: data.adobe_label || 'Adobe Sign', + 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-adobe', state.auth.adobe, 'Adobe Sign', onClickAdobe); + 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,26 +96,19 @@ 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') { - showToast('Adobe Sign disconnected. Reconnect to continue.', 'info'); - await connectAdobeEnv(); - } + await disconnectPlatform(platform, { silent: true, skipRefresh: true }); + showToast('Adobe Sign disconnected. Reconnect to continue.', 'info'); + await connectAdobeEnv(); } async function connectAdobeEnv() { @@ -122,10 +140,11 @@ async function connectDocusign() { try { const data = await api.auth.connectDocusign(); if (data.connected) { - setState('auth', { ...state.auth, docusign: true }); - renderAuthChips(); - const { refreshTemplates } = await import('./templates.js'); - refreshTemplates(); + 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 = ``; } +// ── 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 = ` + + + `; + 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 = `
No matching accounts
`; + return; + } + + listEl.innerHTML = filtered.map(acc => ` + + `).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() { @@ -249,7 +385,7 @@ async function showAdobeOAuthDialog() { `; document.body.appendChild(dialog); - document.getElementById('adobe-dialog-close').onclick = () => dialog.remove(); + document.getElementById('adobe-dialog-close').onclick = () => dialog.remove(); document.getElementById('adobe-dialog-cancel').onclick = () => dialog.remove(); document.getElementById('adobe-dialog-submit').onclick = () => submitAdobeCode(dialog); document.getElementById('adobe-redirect-input').addEventListener('keydown', e => { @@ -262,13 +398,13 @@ async function submitAdobeCode(dialog) { if (!url) return; const submitBtn = document.getElementById('adobe-dialog-submit'); - const errorEl = document.getElementById('adobe-dialog-error'); + const errorEl = document.getElementById('adobe-dialog-error'); submitBtn.disabled = true; submitBtn.textContent = 'Connecting…'; errorEl.textContent = ''; try { - const data = await api.auth.exchangeAdobe(url); + await api.auth.exchangeAdobe(url); dialog.remove(); setState('auth', { ...state.auth, adobe: true }); renderAuthChips(); @@ -296,10 +432,10 @@ export function showToast(message, type = 'info') { const borders = { info: 'var(--cobalt)', error: 'var(--error)', success: 'var(--success)' }; 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; + background:${colors[type] || colors.info};border:1px solid ${borders[type] || borders.info}; + 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); } diff --git a/web/static/js/settings.js b/web/static/js/settings.js index 35330d9..6dc8582 100644 --- a/web/static/js/settings.js +++ b/web/static/js/settings.js @@ -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() {
Docusign - ${data.docusign ? 'Connected' : 'Not connected'} + ${data.docusign ? (data.docusign_account_selection_required ? 'Connected — account selection required' : 'Connected') : 'Not connected'} - ${data.docusign ? '● Connected' : '○ Disconnected'} +
@@ -180,6 +179,7 @@ async function _loadConnInfo() { +
@@ -200,7 +200,7 @@ async function _loadConnInfo() {
Switch Account Note - Use Switch Docusign Account 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. + Use Choose Account or Switch Docusign Account to select from the DocuSign accounts available to this login. The picker is sorted alphabetically and supports search.
@@ -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(); + } } diff --git a/web/static/js/state.js b/web/static/js/state.js index 8eab59c..3a8f917 100644 --- a/web/static/js/state.js +++ b/web/static/js/state.js @@ -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(),