Add DocuSign account picker
This commit is contained in:
parent
af92aa6c47
commit
90113a6514
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -10,22 +10,34 @@ export async function refreshAuth() {
|
||||||
try {
|
try {
|
||||||
const data = await api.auth.status();
|
const data = await api.auth.status();
|
||||||
setState('auth', {
|
setState('auth', {
|
||||||
adobe: !!data.adobe,
|
adobe: !!data.adobe,
|
||||||
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,26 +96,19 @@ 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() {
|
||||||
|
|
@ -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() {
|
||||||
|
|
@ -249,7 +385,7 @@ async function showAdobeOAuthDialog() {
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(dialog);
|
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-cancel').onclick = () => dialog.remove();
|
||||||
document.getElementById('adobe-dialog-submit').onclick = () => submitAdobeCode(dialog);
|
document.getElementById('adobe-dialog-submit').onclick = () => submitAdobeCode(dialog);
|
||||||
document.getElementById('adobe-redirect-input').addEventListener('keydown', e => {
|
document.getElementById('adobe-redirect-input').addEventListener('keydown', e => {
|
||||||
|
|
@ -262,13 +398,13 @@ async function submitAdobeCode(dialog) {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
const submitBtn = document.getElementById('adobe-dialog-submit');
|
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.disabled = true;
|
||||||
submitBtn.textContent = 'Connecting…';
|
submitBtn.textContent = 'Connecting…';
|
||||||
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();
|
||||||
|
|
@ -296,10 +432,10 @@ export function showToast(message, type = 'info') {
|
||||||
const borders = { info: 'var(--cobalt)', error: 'var(--error)', success: 'var(--success)' };
|
const borders = { info: 'var(--cobalt)', error: 'var(--error)', success: 'var(--success)' };
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue