Merge branch 'ui-redesign'

This commit is contained in:
Paul Huliganga 2026-04-21 20:31:13 -04:00
commit b8dbad73ac
8 changed files with 167 additions and 205 deletions

View File

@ -19,21 +19,13 @@ ADOBE_SIGN_BASE_URL=https://api.eu2.adobesign.com/api/rest/v6
# Integration key (client ID) from the DocuSign developer console # Integration key (client ID) from the DocuSign developer console
DOCUSIGN_CLIENT_ID=your-integration-key DOCUSIGN_CLIENT_ID=your-integration-key
# Client secret — only needed for the one-time Auth Code Grant consent flow # Client secret used for the Auth Code Grant and refresh-token exchange
DOCUSIGN_CLIENT_SECRET=your-client-secret DOCUSIGN_CLIENT_SECRET=your-client-secret
# GUID of the DocuSign user to impersonate via JWT grant
# Found in the DocuSign admin UI under Users → user details
DOCUSIGN_USER_ID=your-docusign-user-guid
# Account ID of the target DocuSign account # Account ID of the target DocuSign account
# Found in the DocuSign admin UI under Settings → Account Profile # Found in the DocuSign admin UI under Settings → Account Profile
DOCUSIGN_ACCOUNT_ID=your-docusign-account-id DOCUSIGN_ACCOUNT_ID=your-docusign-account-id
# Path to the RSA private key file used for JWT signing
# Generate a keypair in the DocuSign developer console and save the private key here
DOCUSIGN_PRIVATE_KEY_PATH=/path/to/private.key
# OAuth auth server — use account-d.docusign.com for sandbox, account.docusign.com for production # OAuth auth server — use account-d.docusign.com for sandbox, account.docusign.com for production
DOCUSIGN_AUTH_SERVER=account-d.docusign.com DOCUSIGN_AUTH_SERVER=account-d.docusign.com
@ -42,10 +34,11 @@ DOCUSIGN_AUTH_SERVER=account-d.docusign.com
# Production: https://na3.docusign.net/restapi (replace na3 with your shard) # Production: https://na3.docusign.net/restapi (replace na3 with your shard)
DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi
# Redirect URI registered in your DocuSign app (used only during one-time consent flow) # Redirect URI registered in your DocuSign app
DOCUSIGN_REDIRECT_URI=http://localhost:8080/callback DOCUSIGN_REDIRECT_URI=http://localhost:8000/api/auth/docusign/callback
# Auto-written by src/docusign_auth.py to cache the JWT access token. # Auto-written by src/docusign_auth.py after the initial authorization flow.
# Leave blank; they will be populated automatically. # Leave blank; they will be populated automatically.
DOCUSIGN_ACCESS_TOKEN= DOCUSIGN_ACCESS_TOKEN=
DOCUSIGN_REFRESH_TOKEN=
DOCUSIGN_TOKEN_EXPIRY= DOCUSIGN_TOKEN_EXPIRY=

View File

@ -10,7 +10,7 @@ It downloads templates via the Adobe Sign API, converts them to DocuSign format,
1. **Authenticates** with Adobe Sign via OAuth (one-time browser flow, tokens saved to `.env`) 1. **Authenticates** with Adobe Sign via OAuth (one-time browser flow, tokens saved to `.env`)
2. **Downloads** templates — PDF, metadata, and form field definitions 2. **Downloads** templates — PDF, metadata, and form field definitions
3. **Converts** each template to a DocuSign `envelopeTemplate` JSON, mapping all field types, coordinates, recipient roles, and conditional field logic 3. **Converts** each template to a DocuSign `envelopeTemplate` JSON, mapping all field types, coordinates, recipient roles, and conditional field logic
4. **Authenticates** with DocuSign via JWT grant (one-time browser consent, then fully automated) 4. **Authenticates** with DocuSign via OAuth Authorization Code Grant (one-time browser login, then refresh-token based)
5. **Uploads** the converted template to DocuSign via the REST API 5. **Uploads** the converted template to DocuSign via the REST API
--- ---
@ -19,7 +19,7 @@ It downloads templates via the Adobe Sign API, converts them to DocuSign format,
- Python 3.10+ - Python 3.10+
- An Adobe Sign OAuth app (EU2 shard) with scopes: `library_read:self library_write:self user_read:self` - An Adobe Sign OAuth app (EU2 shard) with scopes: `library_read:self library_write:self user_read:self`
- A DocuSign developer account with an integration key and RSA keypair - A DocuSign developer account with an OAuth app client ID and client secret
--- ---
@ -46,14 +46,13 @@ python3 src/adobe_auth.py
Opens a browser. After authorizing, paste the redirect URL back into the terminal. Opens a browser. After authorizing, paste the redirect URL back into the terminal.
Tokens are saved to `.env` and auto-refreshed on subsequent runs. Tokens are saved to `.env` and auto-refreshed on subsequent runs.
**4. Grant consent for DocuSign** (one-time per user): **4. Authorize DocuSign** (one-time per user):
```bash ```bash
python3 src/docusign_auth.py --consent python3 src/docusign_auth.py --authorize
``` ```
Opens a browser for the DocuSign OAuth consent screen. After approving, paste the Opens a browser for the DocuSign OAuth screen. After approving, paste the
redirect URL back into the terminal. This grants the `impersonation` scope required redirect URL back into the terminal. The app stores both access and refresh
for JWT grant. After this runs once, all subsequent API calls use JWT automatically — tokens in `.env`, and later API calls refresh access tokens automatically.
no further browser interaction needed.
--- ---
@ -274,7 +273,7 @@ src/
adobe_api.py # Adobe Sign API client (auto token refresh) adobe_api.py # Adobe Sign API client (auto token refresh)
download_templates.py # List and download templates from Adobe Sign download_templates.py # List and download templates from Adobe Sign
compose_docusign_template.py # Core conversion: Adobe Sign → DocuSign JSON compose_docusign_template.py # Core conversion: Adobe Sign → DocuSign JSON
docusign_auth.py # DocuSign JWT auth + one-time consent flow docusign_auth.py # DocuSign auth-code + refresh-token helper
upload_docusign_template.py # Upsert upload: PUT if exists, POST if not upload_docusign_template.py # Upsert upload: PUT if exists, POST if not
migrate_template.py # End-to-end CLI runner (download → convert → upload) migrate_template.py # End-to-end CLI runner (download → convert → upload)

View File

@ -1,31 +1,23 @@
""" """
docusign_auth.py docusign_auth.py
---------------- ----------------
Handles DocuSign authentication for the migration toolkit. Handles DocuSign OAuth using the Authorization Code Grant.
Two flows:
JWT Grant service-to-service, no user interaction. Used for all
normal API calls. Requires consent to have been granted.
Auth Code Grant browser-based OAuth flow. Run once with --consent to
grant the app the 'impersonation' scope it needs for JWT.
Usage: Usage:
python3 src/docusign_auth.py --consent # one-time browser consent python3 src/docusign_auth.py --authorize # one-time browser login
python3 src/docusign_auth.py # print a fresh access token (smoke test) python3 src/docusign_auth.py # print a fresh access token
Required .env keys: Required .env keys:
DOCUSIGN_CLIENT_ID Integration key from your DocuSign app DOCUSIGN_CLIENT_ID
DOCUSIGN_USER_ID GUID of the DocuSign user the app will act as DOCUSIGN_CLIENT_SECRET
DOCUSIGN_ACCOUNT_ID Your DocuSign account ID DOCUSIGN_AUTH_SERVER
DOCUSIGN_PRIVATE_KEY_PATH Path to your RSA private key (.pem or .key) DOCUSIGN_REDIRECT_URI
DOCUSIGN_AUTH_SERVER account-d.docusign.com (sandbox) DOCUSIGN_BASE_URL
or account.docusign.com (production)
DOCUSIGN_BASE_URL https://demo.docusign.net/restapi (sandbox)
or https://na3.docusign.net/restapi (prod, check your account)
For --consent only: Auto-written to .env after authorization:
DOCUSIGN_CLIENT_SECRET OAuth client secret DOCUSIGN_ACCESS_TOKEN
DOCUSIGN_REDIRECT_URI Must match your app config (default: http://localhost:8080/callback) DOCUSIGN_REFRESH_TOKEN
DOCUSIGN_TOKEN_EXPIRY
""" """
import argparse import argparse
@ -33,89 +25,36 @@ import os
import sys import sys
import time import time
import webbrowser import webbrowser
from urllib.parse import urlencode, urlparse, parse_qs from urllib.parse import parse_qs, urlencode, urlparse
import jwt
import requests import requests
from dotenv import load_dotenv, set_key from dotenv import load_dotenv, set_key
load_dotenv() load_dotenv()
ENV_FILE = os.path.join(os.path.dirname(__file__), "..", ".env") ENV_FILE = os.path.join(os.path.dirname(__file__), "..", ".env")
TOKEN_EXPIRY_BUFFER = 120 # refresh token 2 minutes before it expires TOKEN_EXPIRY_BUFFER = 120
DOCUSIGN_SCOPE = "signature"
# --------------------------------------------------------------------------- def _required_env(name: str) -> str:
# JWT Grant value = os.getenv(name)
# --------------------------------------------------------------------------- if not value:
raise RuntimeError(f"{name} must be set in .env")
def _load_private_key(): return value
key_path = os.getenv("DOCUSIGN_PRIVATE_KEY_PATH")
if not key_path:
raise RuntimeError("DOCUSIGN_PRIVATE_KEY_PATH is not set in .env")
key_path = os.path.expanduser(key_path)
if not os.path.exists(key_path):
raise RuntimeError(f"Private key not found: {key_path}")
with open(key_path, "r") as f:
return f.read()
def _request_jwt_token(): def _auth_server() -> str:
"""Exchange a JWT assertion for a DocuSign access token.""" return os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
client_id = os.getenv("DOCUSIGN_CLIENT_ID")
user_id = os.getenv("DOCUSIGN_USER_ID")
auth_server = os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
private_key = _load_private_key()
if not all([client_id, user_id]):
raise RuntimeError("DOCUSIGN_CLIENT_ID and DOCUSIGN_USER_ID must be set in .env")
now = int(time.time())
payload = {
"iss": client_id,
"sub": user_id,
"aud": auth_server,
"iat": now,
"exp": now + 3600,
"scope": "signature impersonation",
}
assertion = jwt.encode(payload, private_key, algorithm="RS256")
# PyJWT >= 2.0 returns str; older versions return bytes
if isinstance(assertion, bytes):
assertion = assertion.decode("utf-8")
resp = requests.post(
f"https://{auth_server}/oauth/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": assertion,
},
)
if resp.status_code == 400 and "consent_required" in resp.text:
raise RuntimeError(
"Consent not yet granted for this app/user combination.\n"
"Run: python3 src/docusign_auth.py --consent\n"
"Then retry."
)
resp.raise_for_status()
return resp.json()
def get_access_token() -> str: def _redirect_uri() -> str:
""" return os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8000/api/auth/docusign/callback")
Return a valid DocuSign access token, refreshing via JWT grant if needed.
Caches the token in .env to avoid unnecessary round-trips.
"""
cached_token = os.getenv("DOCUSIGN_ACCESS_TOKEN")
cached_expiry = os.getenv("DOCUSIGN_TOKEN_EXPIRY")
if cached_token and cached_expiry:
if int(time.time()) < int(cached_expiry) - TOKEN_EXPIRY_BUFFER:
return cached_token
token_data = _request_jwt_token() def _persist_token_data(token_data: dict) -> str:
access_token = token_data["access_token"] access_token = token_data["access_token"]
refresh_token = token_data.get("refresh_token")
expiry = int(time.time()) + int(token_data.get("expires_in", 3600)) expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
abs_env = os.path.abspath(ENV_FILE) abs_env = os.path.abspath(ENV_FILE)
@ -124,42 +63,34 @@ def get_access_token() -> str:
os.environ["DOCUSIGN_ACCESS_TOKEN"] = access_token os.environ["DOCUSIGN_ACCESS_TOKEN"] = access_token
os.environ["DOCUSIGN_TOKEN_EXPIRY"] = str(expiry) os.environ["DOCUSIGN_TOKEN_EXPIRY"] = str(expiry)
if refresh_token:
set_key(abs_env, "DOCUSIGN_REFRESH_TOKEN", refresh_token)
os.environ["DOCUSIGN_REFRESH_TOKEN"] = refresh_token
return access_token return access_token
# --------------------------------------------------------------------------- def build_authorization_url() -> str:
# Auth Code Grant — consent flow client_id = _required_env("DOCUSIGN_CLIENT_ID")
# ---------------------------------------------------------------------------
def _build_consent_url():
client_id = os.getenv("DOCUSIGN_CLIENT_ID")
auth_server = os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
redirect_uri = os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8080/callback")
params = { params = {
"response_type": "code", "response_type": "code",
"scope": "signature impersonation", "scope": DOCUSIGN_SCOPE,
"client_id": client_id, "client_id": client_id,
"redirect_uri": redirect_uri, "redirect_uri": _redirect_uri(),
} }
return f"https://{auth_server}/oauth/auth?{urlencode(params)}" return f"https://{_auth_server()}/oauth/auth?{urlencode(params)}"
def _exchange_code(code: str): def exchange_code_for_token(code: str) -> dict:
client_id = os.getenv("DOCUSIGN_CLIENT_ID") client_id = _required_env("DOCUSIGN_CLIENT_ID")
client_secret = os.getenv("DOCUSIGN_CLIENT_SECRET") client_secret = _required_env("DOCUSIGN_CLIENT_SECRET")
auth_server = os.getenv("DOCUSIGN_AUTH_SERVER", "account-d.docusign.com")
redirect_uri = os.getenv("DOCUSIGN_REDIRECT_URI", "http://localhost:8080/callback")
if not client_secret:
raise RuntimeError("DOCUSIGN_CLIENT_SECRET must be set in .env for the consent flow")
resp = requests.post( resp = requests.post(
f"https://{auth_server}/oauth/token", f"https://{_auth_server()}/oauth/token",
data={ data={
"grant_type": "authorization_code", "grant_type": "authorization_code",
"code": code, "code": code,
"redirect_uri": redirect_uri, "redirect_uri": _redirect_uri(),
}, },
auth=(client_id, client_secret), auth=(client_id, client_secret),
) )
@ -167,18 +98,53 @@ def _exchange_code(code: str):
return resp.json() return resp.json()
def run_consent_flow(): def refresh_access_token(refresh_token: str | None = None) -> dict:
""" client_id = _required_env("DOCUSIGN_CLIENT_ID")
Open a browser for the user to grant consent, then exchange the code for client_secret = _required_env("DOCUSIGN_CLIENT_SECRET")
an initial access token. After this succeeds, JWT grant will work. refresh_token = refresh_token or os.getenv("DOCUSIGN_REFRESH_TOKEN")
"""
url = _build_consent_url() if not refresh_token:
print("\nOpening browser for DocuSign consent...") raise RuntimeError(
"No DocuSign refresh token found. Run: python3 src/docusign_auth.py --authorize"
)
resp = requests.post(
f"https://{_auth_server()}/oauth/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
auth=(client_id, client_secret),
)
resp.raise_for_status()
return resp.json()
def save_code_token_exchange(code: str) -> str:
token_data = exchange_code_for_token(code)
return _persist_token_data(token_data)
def get_access_token() -> str:
"""Return a valid DocuSign access token using cached or refreshed OAuth tokens."""
cached_token = os.getenv("DOCUSIGN_ACCESS_TOKEN")
cached_expiry = os.getenv("DOCUSIGN_TOKEN_EXPIRY")
if cached_token and cached_expiry:
if int(time.time()) < int(cached_expiry) - TOKEN_EXPIRY_BUFFER:
return cached_token
token_data = refresh_access_token()
return _persist_token_data(token_data)
def run_authorize_flow():
url = build_authorization_url()
print("\nOpening browser for DocuSign authorization...")
print(f"\nIf the browser doesn't open, go to:\n{url}\n") print(f"\nIf the browser doesn't open, go to:\n{url}\n")
webbrowser.open(url) webbrowser.open(url)
print("Log in and click Allow. The browser will redirect to your redirect URI") print("Log in, approve access, then paste the full redirect URL here.\n")
print("(the page may show an error — that's fine). Copy the full URL and paste it here.\n")
redirected_url = input("Paste the redirect URL: ").strip() redirected_url = input("Paste the redirect URL: ").strip()
parsed = urlparse(redirected_url) parsed = urlparse(redirected_url)
@ -189,37 +155,32 @@ def run_consent_flow():
print(f"ERROR: {error}") print(f"ERROR: {error}")
sys.exit(1) sys.exit(1)
if "code" not in params: code_list = params.get("code")
if not code_list:
print("ERROR: No authorization code found in the URL.") print("ERROR: No authorization code found in the URL.")
sys.exit(1) sys.exit(1)
code = params["code"][0]
print("Exchanging code for token...") print("Exchanging code for token...")
token_data = _exchange_code(code) save_code_token_exchange(code_list[0])
print("Authorization complete. Access and refresh tokens were saved to .env.")
access_token = token_data["access_token"]
expiry = int(time.time()) + int(token_data.get("expires_in", 3600))
abs_env = os.path.abspath(ENV_FILE)
set_key(abs_env, "DOCUSIGN_ACCESS_TOKEN", access_token)
set_key(abs_env, "DOCUSIGN_TOKEN_EXPIRY", str(expiry))
print("Consent granted and token saved.")
print("JWT grant will now work for future calls — you won't need to run --consent again.")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main(): def main():
parser = argparse.ArgumentParser(description="DocuSign authentication helper") parser = argparse.ArgumentParser(description="DocuSign authentication helper")
parser.add_argument("--consent", action="store_true", parser.add_argument(
help="Run the Auth Code Grant consent flow (required once per user/app)") "--authorize",
action="store_true",
help="Run the Auth Code Grant flow and store refresh credentials",
)
parser.add_argument(
"--consent",
action="store_true",
help="Backward-compatible alias for --authorize",
)
args = parser.parse_args() args = parser.parse_args()
if args.consent: if args.authorize or args.consent:
run_consent_flow() run_authorize_flow()
else: else:
token = get_access_token() token = get_access_token()
print(f"Access token: {token[:20]}... (valid for ~1 hour)") print(f"Access token: {token[:20]}... (valid for ~1 hour)")

View File

@ -2,7 +2,7 @@
upload_docusign_template.py upload_docusign_template.py
--------------------------- ---------------------------
Uploads a DocuSign template JSON file to DocuSign via the REST API. Uploads a DocuSign template JSON file to DocuSign via the REST API.
Authenticates using JWT grant (no Node.js dependency required). Authenticates using DocuSign OAuth tokens stored in .env.
By default uses upsert: if a template with the same name already exists, By default uses upsert: if a template with the same name already exists,
the most recently modified one is updated (PUT). Use --force-create to the most recently modified one is updated (PUT). Use --force-create to
@ -13,12 +13,12 @@ Usage:
python3 src/upload_docusign_template.py --file <path> --force-create python3 src/upload_docusign_template.py --file <path> --force-create
First-time setup: First-time setup:
python3 src/docusign_auth.py --consent # grant consent once python3 src/docusign_auth.py --authorize # authorize once
python3 src/upload_docusign_template.py --file <path> python3 src/upload_docusign_template.py --file <path>
Required .env keys (see docusign_auth.py for full list): Required .env keys (see docusign_auth.py for full list):
DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_ACCOUNT_ID, DOCUSIGN_CLIENT_ID, DOCUSIGN_CLIENT_SECRET, DOCUSIGN_ACCOUNT_ID,
DOCUSIGN_PRIVATE_KEY_PATH, DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL DOCUSIGN_AUTH_SERVER, DOCUSIGN_BASE_URL, DOCUSIGN_REDIRECT_URI
""" """
import argparse import argparse

View File

@ -33,7 +33,7 @@ Then open [http://localhost:8000](http://localhost:8000).
- [ ] Top bar shows two disconnected chips (red dot): "Adobe Sign" and "DocuSign" - [ ] Top bar shows two disconnected chips (red dot): "Adobe Sign" and "DocuSign"
- [ ] Click "Adobe Sign" chip → connects via `.env` refresh token → chip turns green - [ ] Click "Adobe Sign" chip → connects via `.env` refresh token → chip turns green
- [ ] Click "DocuSign" chip → connects via JWT grant → chip turns green - [ ] Click "DocuSign" chip → redirects through OAuth if needed, then chip turns green
- [ ] Disconnecting either chip → chip turns red → templates clear - [ ] Disconnecting either chip → chip turns red → templates clear
## 4. Templates View ## 4. Templates View

View File

@ -96,11 +96,10 @@ def test_adobe_exchange_rejects_missing_code():
def test_docusign_connect_stores_token(): def test_docusign_connect_stores_token():
"""GET /api/auth/docusign/connect uses JWT grant from .env → session connected.""" """GET /api/auth/docusign/connect uses cached OAuth credentials → session connected."""
from unittest.mock import patch from unittest.mock import patch
import web.routers.auth as auth_module
with patch("docusign_auth.get_access_token", return_value="ds-jwt-token"): with patch("docusign_auth.get_access_token", return_value="ds-oauth-token"):
resp = client.get("/api/auth/docusign/connect") resp = client.get("/api/auth/docusign/connect")
assert resp.status_code == 200 assert resp.status_code == 200
@ -113,6 +112,19 @@ def test_docusign_connect_stores_token():
assert status_resp.json()["docusign"] is True assert status_resp.json()["docusign"] is True
def test_docusign_connect_requests_authorization_when_refresh_token_missing():
"""GET /api/auth/docusign/connect returns an auth URL when first-time authorization is needed."""
from unittest.mock import patch
with patch("docusign_auth.get_access_token", side_effect=RuntimeError("No DocuSign refresh token found")), \
patch("docusign_auth.build_authorization_url", return_value="https://example.com/oauth"):
resp = client.get("/api/auth/docusign/connect")
assert resp.status_code == 200
assert resp.json()["authorization_required"] is True
assert resp.json()["authorization_url"] == "https://example.com/oauth"
@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."""

View File

@ -164,24 +164,33 @@ def adobe_disconnect(request: Request):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DocuSign — JWT grant (.env) or OAuth redirect # DocuSign — Auth Code Grant + refresh token
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/docusign/connect") @router.get("/docusign/connect")
def docusign_connect(request: Request): def docusign_connect(request: Request):
""" """
Obtain a DocuSign access token via JWT grant using the credentials already Obtain a DocuSign access token from cached OAuth credentials in .env.
in .env (DOCUSIGN_CLIENT_ID, DOCUSIGN_USER_ID, DOCUSIGN_PRIVATE_KEY_PATH). If the app has not been authorized yet, return the authorization URL so the
No browser sign-in needed consent was already granted via the CLI setup. frontend can start the browser flow.
""" """
import sys import sys
import os import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from docusign_auth import get_access_token from docusign_auth import build_authorization_url, get_access_token
try: try:
token = get_access_token() token = get_access_token()
except RuntimeError as e: except RuntimeError as e:
if "refresh token" in str(e).lower():
return JSONResponse(
{
"connected": False,
"authorization_required": True,
"authorization_url": build_authorization_url(),
},
status_code=200,
)
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
session = get_session(request) session = get_session(request)
@ -194,46 +203,32 @@ def docusign_connect(request: Request):
@router.get("/docusign/start") @router.get("/docusign/start")
def docusign_start(): def docusign_start():
"""Redirect to DocuSign OAuth (alternative to JWT grant).""" """Redirect to the DocuSign OAuth authorization screen."""
import base64 as _b64 import sys
params = ( import os
f"?response_type=code" sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
f"&scope=signature" from docusign_auth import build_authorization_url
f"&client_id={settings.docusign_client_id}"
f"&redirect_uri={settings.docusign_redirect_uri}" return RedirectResponse(build_authorization_url())
)
return RedirectResponse(f"https://{settings.docusign_auth_server}/oauth/auth" + params)
@router.get("/docusign/callback") @router.get("/docusign/callback")
async def docusign_callback(request: Request, code: str = ""): async def docusign_callback(request: Request, code: str = ""):
"""Handle DocuSign OAuth redirect callback.""" """Handle DocuSign OAuth redirect callback."""
import base64 import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "src"))
from docusign_auth import save_code_token_exchange
if not code: if not code:
return JSONResponse({"error": "missing code"}, status_code=400) return JSONResponse({"error": "missing code"}, status_code=400)
credentials = base64.b64encode(
f"{settings.docusign_client_id}:{settings.docusign_client_secret}".encode()
).decode()
async with httpx.AsyncClient() as client:
resp = await client.post(
f"https://{settings.docusign_auth_server}/oauth/token",
headers={"Authorization": f"Basic {credentials}"},
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": settings.docusign_redirect_uri,
},
)
if not resp.is_success:
return JSONResponse({"error": "token exchange failed", "detail": resp.text}, status_code=502)
token_data = resp.json()
session = get_session(request) session = get_session(request)
session["docusign_access_token"] = token_data.get("access_token") try:
session["docusign_refresh_token"] = token_data.get("refresh_token") session["docusign_access_token"] = save_code_token_exchange(code)
except Exception as e:
return JSONResponse({"error": "token exchange failed", "detail": str(e)}, status_code=502)
session["docusign_refresh_token"] = os.getenv("DOCUSIGN_REFRESH_TOKEN")
response = RedirectResponse("/") response = RedirectResponse("/")
save_session(response, session) save_session(response, session)

View File

@ -100,6 +100,8 @@ async function connectDocusign() {
renderAuthChips(); renderAuthChips();
const { refreshTemplates } = await import('./templates.js'); const { refreshTemplates } = await import('./templates.js');
refreshTemplates(); refreshTemplates();
} else if (data.authorization_required && data.authorization_url) {
window.location.href = data.authorization_url;
} else { } else {
renderAuthChips(); renderAuthChips();
showToast('Docusign error: ' + (data.error || 'unknown'), 'error'); showToast('Docusign error: ' + (data.error || 'unknown'), 'error');