Use Adobe OAuth callback flow in web UI
This commit is contained in:
parent
8f0b14bc62
commit
e19bd68ebd
|
|
@ -13,6 +13,7 @@ ADOBE_CLIENT_SECRET=your-adobe-client-secret
|
||||||
ADOBE_ACCESS_TOKEN=
|
ADOBE_ACCESS_TOKEN=
|
||||||
ADOBE_REFRESH_TOKEN=
|
ADOBE_REFRESH_TOKEN=
|
||||||
ADOBE_SIGN_BASE_URL=https://api.eu2.adobesign.com/api/rest/v6
|
ADOBE_SIGN_BASE_URL=https://api.eu2.adobesign.com/api/rest/v6
|
||||||
|
ADOBE_REDIRECT_URI=http://localhost:8000/api/auth/adobe/callback
|
||||||
|
|
||||||
# ─── DocuSign ────────────────────────────────────────────────────────────────
|
# ─── DocuSign ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ variables with descriptions. Use `account-d.docusign.com` and
|
||||||
`https://demo.docusign.net/restapi` for sandbox; for production replace with
|
`https://demo.docusign.net/restapi` for sandbox; for production replace with
|
||||||
`account.docusign.com` and your account's base URL (e.g. `https://na3.docusign.net/restapi`).
|
`account.docusign.com` and your account's base URL (e.g. `https://na3.docusign.net/restapi`).
|
||||||
|
|
||||||
**3. Authenticate with Adobe Sign** (one-time):
|
**3. Authenticate with Adobe Sign** (one-time for CLI use):
|
||||||
```bash
|
```bash
|
||||||
python3 src/adobe_auth.py
|
python3 src/adobe_auth.py
|
||||||
```
|
```
|
||||||
|
|
@ -126,6 +126,7 @@ The web UI now supports concurrent testers on one shared deployment:
|
||||||
- each browser gets its own server-side session file
|
- each browser gets its own server-side session file
|
||||||
- DocuSign web OAuth is isolated per tester session
|
- DocuSign web OAuth is isolated per tester session
|
||||||
- migration history and batch-job polling are scoped to that tester session
|
- migration history and batch-job polling are scoped to that tester session
|
||||||
|
- Adobe Sign web auth now supports the same redirect-based browser callback pattern as DocuSign
|
||||||
- Adobe Sign can still be connected from shared `.env` credentials if you use the top-bar Adobe connect flow
|
- Adobe Sign can still be connected from shared `.env` credentials if you use the top-bar Adobe connect flow
|
||||||
|
|
||||||
Important behavior:
|
Important behavior:
|
||||||
|
|
@ -152,6 +153,7 @@ 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.
|
||||||
|
- Adobe Sign also supports a normal browser redirect callback when shared `.env` credentials are not being used.
|
||||||
- If your DocuSign login belongs to multiple accounts, the app will prompt you to choose one account for this session.
|
- 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.
|
- 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:
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ def _userinfo_payload():
|
||||||
def temp_session_store(tmp_path, monkeypatch):
|
def temp_session_store(tmp_path, monkeypatch):
|
||||||
import web.config as cfg
|
import web.config as cfg
|
||||||
monkeypatch.setattr(cfg.settings, "session_store_dir", str(tmp_path / ".session-store"))
|
monkeypatch.setattr(cfg.settings, "session_store_dir", str(tmp_path / ".session-store"))
|
||||||
|
monkeypatch.setattr(cfg.settings, "adobe_redirect_uri", "http://localhost:8000/api/auth/adobe/callback")
|
||||||
client.cookies.clear()
|
client.cookies.clear()
|
||||||
yield
|
yield
|
||||||
client.cookies.clear()
|
client.cookies.clear()
|
||||||
|
|
@ -64,8 +65,8 @@ def test_adobe_url_returns_auth_url():
|
||||||
assert "url" in data
|
assert "url" in data
|
||||||
assert "adobesign.com" in data["url"]
|
assert "adobesign.com" in data["url"]
|
||||||
assert "response_type=code" in data["url"]
|
assert "response_type=code" in data["url"]
|
||||||
# Must use the registered redirect URI
|
assert "redirect_uri=http://localhost:8000/api/auth/adobe/callback" in data["url"]
|
||||||
assert "localhost%3A8080" in data["url"] or "localhost:8080" in data["url"]
|
assert resp.cookies.get("migrator_session") is not None
|
||||||
|
|
||||||
|
|
||||||
def test_adobe_connect_env_stores_token(monkeypatch):
|
def test_adobe_connect_env_stores_token(monkeypatch):
|
||||||
|
|
@ -93,13 +94,14 @@ def test_adobe_connect_env_stores_token(monkeypatch):
|
||||||
assert status_resp.json()["adobe_label"] == "Paul Sandbox"
|
assert status_resp.json()["adobe_label"] == "Paul Sandbox"
|
||||||
|
|
||||||
|
|
||||||
def test_adobe_connect_env_fails_without_credentials(monkeypatch):
|
def test_adobe_connect_requests_authorization_without_credentials(monkeypatch):
|
||||||
"""GET /api/auth/adobe/connect with no .env tokens → 400."""
|
"""GET /api/auth/adobe/connect with no .env tokens returns an auth URL."""
|
||||||
monkeypatch.delenv("ADOBE_ACCESS_TOKEN", raising=False)
|
monkeypatch.delenv("ADOBE_ACCESS_TOKEN", raising=False)
|
||||||
monkeypatch.delenv("ADOBE_REFRESH_TOKEN", raising=False)
|
monkeypatch.delenv("ADOBE_REFRESH_TOKEN", raising=False)
|
||||||
resp = client.get("/api/auth/adobe/connect")
|
resp = client.get("/api/auth/adobe/connect")
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 200
|
||||||
assert "No Adobe Sign credentials" in resp.json()["error"]
|
assert resp.json()["authorization_required"] is True
|
||||||
|
assert "/api/auth/adobe/callback" in resp.json()["authorization_url"]
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
|
|
@ -126,6 +128,48 @@ def test_adobe_exchange_stores_token():
|
||||||
assert status_resp.json()["adobe"] is True
|
assert status_resp.json()["adobe"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
def test_adobe_callback_stores_session_tokens():
|
||||||
|
"""GET /api/auth/adobe/callback stores tokens in this browser session."""
|
||||||
|
respx.post(_ADOBE_TOKEN_URL).mock(
|
||||||
|
return_value=httpx.Response(200, json={
|
||||||
|
"access_token": "adobe-test-token",
|
||||||
|
"refresh_token": "adobe-refresh",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
cookie = create_test_session({"adobe_oauth_state": "expected-state"})
|
||||||
|
with patch("web.routers.auth._fetch_adobe_profile", return_value={
|
||||||
|
"adobe_user_name": "Paul Adobe",
|
||||||
|
"adobe_user_email": "paul@example.com",
|
||||||
|
"adobe_account_name": "Paul Sandbox",
|
||||||
|
"adobe_account_id": "adobe-account-123",
|
||||||
|
}):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/auth/adobe/callback?code=authcode123&state=expected-state",
|
||||||
|
cookies={"migrator_session": cookie},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code in (302, 307)
|
||||||
|
session_cookie = resp.cookies.get("migrator_session")
|
||||||
|
status_resp = client.get("/api/auth/status", cookies={"migrator_session": session_cookie})
|
||||||
|
assert status_resp.json()["adobe"] is True
|
||||||
|
assert status_resp.json()["adobe_auth_mode"] == "session_oauth"
|
||||||
|
|
||||||
|
|
||||||
|
def test_adobe_callback_requires_matching_state():
|
||||||
|
cookie = create_test_session({"adobe_oauth_state": "expected-state"})
|
||||||
|
resp = client.get(
|
||||||
|
"/api/auth/adobe/callback?code=authcode123&state=wrong-state",
|
||||||
|
cookies={"migrator_session": cookie},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "invalid oauth state" in resp.json()["error"]
|
||||||
|
|
||||||
|
|
||||||
def test_adobe_exchange_rejects_missing_code():
|
def test_adobe_exchange_rejects_missing_code():
|
||||||
"""POST /api/auth/adobe/exchange with no code in URL → 400."""
|
"""POST /api/auth/adobe/exchange with no code in URL → 400."""
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
|
|
@ -274,7 +318,7 @@ def test_disconnect_clears_token():
|
||||||
# Connect Adobe via exchange
|
# Connect Adobe via exchange
|
||||||
connect_resp = client.post(
|
connect_resp = client.post(
|
||||||
"/api/auth/adobe/exchange",
|
"/api/auth/adobe/exchange",
|
||||||
json={"redirect_url": "https://localhost:8080/callback?code=abc"},
|
json={"redirect_url": "http://localhost:8000/api/auth/adobe/callback?code=abc"},
|
||||||
)
|
)
|
||||||
session_cookie = connect_resp.cookies["migrator_session"]
|
session_cookie = connect_resp.cookies["migrator_session"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,9 @@ web/routers/auth.py
|
||||||
-------------------
|
-------------------
|
||||||
OAuth endpoints for Adobe Sign and DocuSign.
|
OAuth endpoints for Adobe Sign and DocuSign.
|
||||||
|
|
||||||
Adobe Sign uses the same redirect URI as the CLI (https://localhost:8080/callback).
|
Both providers now support standard browser redirect callbacks handled directly by
|
||||||
Since nothing runs on that port, the browser lands on a failed page. The user copies
|
this server. Tokens are stored in a server-side session keyed by a signed browser
|
||||||
the URL and submits it via POST /api/auth/adobe/exchange — identical to the CLI flow.
|
cookie.
|
||||||
|
|
||||||
DocuSign uses a standard redirect callback handled directly by this server.
|
|
||||||
|
|
||||||
Tokens are stored in a server-side session keyed by a signed browser cookie.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
|
@ -35,12 +31,44 @@ from web.session import get_session, save_session, session_public_view
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Adobe Sign registers https://localhost:8080/callback — same as the CLI script.
|
|
||||||
_ADOBE_REDIRECT_URI = "https://localhost:8080/callback"
|
|
||||||
_ADOBE_AUTH_URL = "https://secure.eu2.adobesign.com/public/oauth/v2"
|
_ADOBE_AUTH_URL = "https://secure.eu2.adobesign.com/public/oauth/v2"
|
||||||
_ADOBE_TOKEN_URL = "https://api.eu2.adobesign.com/oauth/v2/token"
|
_ADOBE_TOKEN_URL = "https://api.eu2.adobesign.com/oauth/v2/token"
|
||||||
|
|
||||||
|
|
||||||
|
def _adobe_redirect_uri() -> str:
|
||||||
|
return settings.adobe_redirect_uri
|
||||||
|
|
||||||
|
|
||||||
|
def _build_adobe_authorization_url(state: str) -> str:
|
||||||
|
return (
|
||||||
|
f"{_ADOBE_AUTH_URL}"
|
||||||
|
f"?response_type=code"
|
||||||
|
f"&client_id={settings.adobe_client_id}"
|
||||||
|
f"&redirect_uri={_adobe_redirect_uri()}"
|
||||||
|
f"&scope=library_read:self+library_write:self+user_read:self"
|
||||||
|
f"&state={state}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_adobe_session_token(refresh_token: str) -> dict:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
_ADOBE_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"client_id": settings.adobe_client_id,
|
||||||
|
"client_secret": settings.adobe_client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not resp.is_success:
|
||||||
|
raise RuntimeError(f"Adobe Sign token refresh failed ({resp.status_code})")
|
||||||
|
token_data = resp.json()
|
||||||
|
if "error" in token_data:
|
||||||
|
raise RuntimeError(token_data.get("error_description", token_data["error"]))
|
||||||
|
return token_data
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_adobe_profile(access_token: str) -> dict:
|
async def _fetch_adobe_profile(access_token: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Best-effort Adobe Sign profile lookup used only for nicer UI labels.
|
Best-effort Adobe Sign profile lookup used only for nicer UI labels.
|
||||||
|
|
@ -122,23 +150,18 @@ def auth_status(request: Request):
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Adobe Sign — manual paste flow (matches CLI behaviour)
|
# Adobe Sign — OAuth Authorization Code Grant
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/adobe/url")
|
@router.get("/adobe/url")
|
||||||
def adobe_auth_url():
|
def adobe_auth_url(request: Request):
|
||||||
"""
|
session = get_session(request)
|
||||||
Return the Adobe Sign authorization URL for the frontend to open in a new tab.
|
state = secrets.token_urlsafe(24)
|
||||||
The user authorizes, lands on a failed page (nothing runs on :8080), copies
|
session["adobe_oauth_state"] = state
|
||||||
the URL, and submits it to POST /api/auth/adobe/exchange.
|
session["adobe_auth_mode"] = "authorization_pending"
|
||||||
"""
|
response = JSONResponse({"url": _build_adobe_authorization_url(state)})
|
||||||
params = (
|
save_session(response, session)
|
||||||
f"?response_type=code"
|
return response
|
||||||
f"&client_id={settings.adobe_client_id}"
|
|
||||||
f"&redirect_uri={_ADOBE_REDIRECT_URI}"
|
|
||||||
f"&scope=library_read:self+library_write:self+user_read:self"
|
|
||||||
)
|
|
||||||
return {"url": _ADOBE_AUTH_URL + params}
|
|
||||||
|
|
||||||
|
|
||||||
class AdobeExchangeRequest(BaseModel):
|
class AdobeExchangeRequest(BaseModel):
|
||||||
|
|
@ -176,7 +199,7 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
"client_id": settings.adobe_client_id,
|
"client_id": settings.adobe_client_id,
|
||||||
"client_secret": settings.adobe_client_secret,
|
"client_secret": settings.adobe_client_secret,
|
||||||
"redirect_uri": _ADOBE_REDIRECT_URI,
|
"redirect_uri": _adobe_redirect_uri(),
|
||||||
"code": code,
|
"code": code,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -206,34 +229,32 @@ async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
||||||
|
|
||||||
|
|
||||||
@router.get("/adobe/connect")
|
@router.get("/adobe/connect")
|
||||||
async def adobe_connect_env(request: Request):
|
async def adobe_connect(request: Request, force_oauth: bool = False):
|
||||||
"""
|
"""
|
||||||
Load Adobe Sign credentials directly from .env (ADOBE_ACCESS_TOKEN /
|
Obtain an Adobe Sign access token for this browser session.
|
||||||
ADOBE_REFRESH_TOKEN). Refreshes the token if needed. No browser login required
|
If session/env tokens are unavailable or force_oauth=true, return an
|
||||||
when a valid refresh token already exists from a previous CLI auth session.
|
authorization URL so the frontend can start a normal OAuth flow.
|
||||||
"""
|
"""
|
||||||
|
session = get_session(request)
|
||||||
|
token = session.get("adobe_access_token")
|
||||||
|
refresh_token = session.get("adobe_refresh_token")
|
||||||
|
|
||||||
|
if not force_oauth and not token and not refresh_token:
|
||||||
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 adobe_api import _refresh_access_token
|
from adobe_api import _refresh_access_token
|
||||||
|
|
||||||
token = os.getenv("ADOBE_ACCESS_TOKEN")
|
env_token = os.getenv("ADOBE_ACCESS_TOKEN")
|
||||||
refresh_token = os.getenv("ADOBE_REFRESH_TOKEN")
|
env_refresh_token = os.getenv("ADOBE_REFRESH_TOKEN")
|
||||||
|
if env_token or env_refresh_token:
|
||||||
if not token and not refresh_token:
|
token = env_token
|
||||||
return JSONResponse(
|
refresh_token = env_refresh_token
|
||||||
{"error": "No Adobe Sign credentials found in .env. Run src/adobe_auth.py first."},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Always refresh to ensure the token is fresh (access tokens expire in ~1h)
|
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
try:
|
try:
|
||||||
token = _refresh_access_token()
|
token = _refresh_access_token()
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
return JSONResponse({"error": str(e)}, status_code=500)
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
session = get_session(request)
|
|
||||||
session["adobe_access_token"] = token
|
session["adobe_access_token"] = token
|
||||||
session["adobe_refresh_token"] = refresh_token
|
session["adobe_refresh_token"] = refresh_token
|
||||||
session["adobe_auth_mode"] = "shared_env"
|
session["adobe_auth_mode"] = "shared_env"
|
||||||
|
|
@ -244,11 +265,102 @@ async def adobe_connect_env(request: Request):
|
||||||
"adobe_connected",
|
"adobe_connected",
|
||||||
{"auth_mode": "shared_env", "source": "server_env"},
|
{"auth_mode": "shared_env", "source": "server_env"},
|
||||||
)
|
)
|
||||||
|
|
||||||
response = JSONResponse({"connected": True})
|
response = JSONResponse({"connected": True})
|
||||||
save_session(response, session)
|
save_session(response, session)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
if not force_oauth and not token and refresh_token:
|
||||||
|
try:
|
||||||
|
token_data = await _refresh_adobe_session_token(refresh_token)
|
||||||
|
except RuntimeError as e:
|
||||||
|
return JSONResponse({"error": str(e)}, status_code=500)
|
||||||
|
token = token_data.get("access_token")
|
||||||
|
session["adobe_access_token"] = token
|
||||||
|
session["adobe_refresh_token"] = token_data.get("refresh_token", refresh_token)
|
||||||
|
session["adobe_auth_mode"] = "session_oauth"
|
||||||
|
session = await _merge_adobe_profile(session, token)
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"adobe_connected",
|
||||||
|
{"auth_mode": "session_oauth", "source": "session_refresh"},
|
||||||
|
)
|
||||||
|
response = JSONResponse({"connected": True})
|
||||||
|
save_session(response, session)
|
||||||
|
return response
|
||||||
|
|
||||||
|
if not force_oauth and token:
|
||||||
|
response = JSONResponse({"connected": True})
|
||||||
|
save_session(response, session)
|
||||||
|
return response
|
||||||
|
|
||||||
|
state = secrets.token_urlsafe(24)
|
||||||
|
session["adobe_oauth_state"] = state
|
||||||
|
session["adobe_auth_mode"] = "authorization_pending"
|
||||||
|
authorization_url = _build_adobe_authorization_url(state)
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"adobe_authorization_requested",
|
||||||
|
{"auth_mode": "authorization_pending"},
|
||||||
|
)
|
||||||
|
response = JSONResponse(
|
||||||
|
{
|
||||||
|
"connected": False,
|
||||||
|
"authorization_required": True,
|
||||||
|
"authorization_url": authorization_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
save_session(response, session)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/adobe/callback")
|
||||||
|
async def adobe_callback(request: Request, code: str = "", state: str = ""):
|
||||||
|
"""Handle Adobe Sign OAuth redirect callback."""
|
||||||
|
if not code:
|
||||||
|
return JSONResponse({"error": "missing code"}, status_code=400)
|
||||||
|
|
||||||
|
session = get_session(request)
|
||||||
|
expected_state = session.get("adobe_oauth_state")
|
||||||
|
if not expected_state or state != expected_state:
|
||||||
|
return JSONResponse({"error": "invalid oauth state"}, status_code=400)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
_ADOBE_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": settings.adobe_client_id,
|
||||||
|
"client_secret": settings.adobe_client_secret,
|
||||||
|
"redirect_uri": _adobe_redirect_uri(),
|
||||||
|
"code": code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not resp.is_success:
|
||||||
|
return JSONResponse({"error": "token exchange failed", "detail": resp.text}, status_code=502)
|
||||||
|
|
||||||
|
token_data = resp.json()
|
||||||
|
if "error" in token_data:
|
||||||
|
return JSONResponse({"error": token_data.get("error_description", token_data["error"])}, status_code=400)
|
||||||
|
|
||||||
|
session["adobe_access_token"] = token_data.get("access_token")
|
||||||
|
session["adobe_refresh_token"] = token_data.get("refresh_token")
|
||||||
|
session["adobe_auth_mode"] = "session_oauth"
|
||||||
|
session.pop("adobe_oauth_state", None)
|
||||||
|
session = await _merge_adobe_profile(session, session["adobe_access_token"])
|
||||||
|
log_event(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
"adobe_connected",
|
||||||
|
{"auth_mode": "session_oauth", "source": "browser_callback"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = RedirectResponse("/#/settings")
|
||||||
|
save_session(response, session)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/adobe/disconnect")
|
@router.get("/adobe/disconnect")
|
||||||
def adobe_disconnect(request: Request):
|
def adobe_disconnect(request: Request):
|
||||||
|
|
@ -260,6 +372,7 @@ def adobe_disconnect(request: Request):
|
||||||
session.pop("adobe_user_email", None)
|
session.pop("adobe_user_email", None)
|
||||||
session.pop("adobe_account_name", None)
|
session.pop("adobe_account_name", None)
|
||||||
session.pop("adobe_account_id", None)
|
session.pop("adobe_account_id", None)
|
||||||
|
session.pop("adobe_oauth_state", None)
|
||||||
session["adobe_auth_mode"] = "disconnected"
|
session["adobe_auth_mode"] = "disconnected"
|
||||||
log_event(
|
log_event(
|
||||||
request,
|
request,
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ export const api = {
|
||||||
status() {
|
status() {
|
||||||
return GET('/api/auth/status');
|
return GET('/api/auth/status');
|
||||||
},
|
},
|
||||||
connectAdobe() {
|
connectAdobe(forceOauth = false) {
|
||||||
return GET('/api/auth/adobe/connect');
|
return GET(`/api/auth/adobe/connect${forceOauth ? '?force_oauth=true' : ''}`);
|
||||||
},
|
},
|
||||||
adobeUrl() {
|
adobeUrl() {
|
||||||
return GET('/api/auth/adobe/url');
|
return GET('/api/auth/adobe/url');
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ async function onClickAdobe() {
|
||||||
if (state.auth.adobe) {
|
if (state.auth.adobe) {
|
||||||
showAuthMenu('adobe', 'chip-adobe');
|
showAuthMenu('adobe', 'chip-adobe');
|
||||||
} else {
|
} else {
|
||||||
await connectAdobeEnv();
|
await connectAdobe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,30 +135,29 @@ export async function switchAccount(platform) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await disconnectPlatform(platform, { silent: true, skipRefresh: true });
|
await disconnectPlatform(platform, { silent: true, skipRefresh: true });
|
||||||
showToast('Adobe Sign disconnected. Reconnect to continue.', 'info');
|
showToast('Starting a fresh Adobe Sign authorization…', 'info');
|
||||||
await connectAdobeEnv();
|
await connectAdobe(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectAdobeEnv() {
|
async function connectAdobe(forceOauth = false) {
|
||||||
closeAuthMenu();
|
closeAuthMenu();
|
||||||
setChipConnecting('chip-adobe');
|
setChipConnecting('chip-adobe');
|
||||||
try {
|
try {
|
||||||
const data = await api.auth.connectAdobe();
|
const data = await api.auth.connectAdobe(forceOauth);
|
||||||
if (data.connected) {
|
if (data.connected) {
|
||||||
setState('auth', { ...state.auth, adobe: true });
|
setState('auth', { ...state.auth, adobe: true });
|
||||||
renderAuthChips();
|
renderAuthChips();
|
||||||
const { refreshTemplates } = await import('./templates.js');
|
const { refreshTemplates } = await import('./templates.js');
|
||||||
refreshTemplates();
|
refreshTemplates();
|
||||||
} else if (data.error && data.error.includes('No Adobe Sign credentials')) {
|
} else if (data.authorization_required && data.authorization_url) {
|
||||||
renderAuthChips();
|
window.location.href = data.authorization_url;
|
||||||
showAdobeOAuthDialog();
|
|
||||||
} else {
|
} else {
|
||||||
renderAuthChips();
|
renderAuthChips();
|
||||||
showToast('Adobe Sign error: ' + (data.error || 'unknown'), 'error');
|
showToast('Adobe Sign error: ' + (data.error || 'unknown'), 'error');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
renderAuthChips();
|
renderAuthChips();
|
||||||
showAdobeOAuthDialog();
|
showToast('Adobe Sign connection failed: ' + e.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,73 +377,6 @@ async function selectDocusignAccount(accountId, errorEl = null) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Adobe OAuth dialog (manual redirect URL paste) ─────────────────────────
|
|
||||||
|
|
||||||
async function showAdobeOAuthDialog() {
|
|
||||||
const { url } = await api.auth.adobeUrl().catch(() => ({ url: '#' }));
|
|
||||||
|
|
||||||
const existing = document.getElementById('adobe-auth-dialog');
|
|
||||||
if (existing) existing.remove();
|
|
||||||
|
|
||||||
const dialog = document.createElement('div');
|
|
||||||
dialog.id = 'adobe-auth-dialog';
|
|
||||||
dialog.innerHTML = `
|
|
||||||
<div class="modal-backdrop"></div>
|
|
||||||
<div class="modal-box">
|
|
||||||
<div class="modal-header">
|
|
||||||
<span class="modal-title">Connect Adobe Sign</span>
|
|
||||||
<button class="btn btn-ghost btn-icon" id="adobe-dialog-close">✕</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<ol style="padding-left:18px;line-height:1.8;margin-bottom:14px;font-size:13px">
|
|
||||||
<li><a href="${escHtml(url)}" target="_blank" rel="noopener" style="color:var(--cobalt)">Click here to authorize in Adobe Sign ↗</a></li>
|
|
||||||
<li>After authorizing, your browser will show a page that fails to load — that's expected.</li>
|
|
||||||
<li>Copy the full URL from the address bar and paste it below.</li>
|
|
||||||
</ol>
|
|
||||||
<input type="text" id="adobe-redirect-input" class="form-input"
|
|
||||||
placeholder="https://localhost:8080/callback?code=…" />
|
|
||||||
<div id="adobe-dialog-error" style="color:var(--error);font-size:12px;min-height:18px;margin-top:6px"></div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="btn btn-secondary" id="adobe-dialog-cancel">Cancel</button>
|
|
||||||
<button class="btn btn-primary" id="adobe-dialog-submit">Connect</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(dialog);
|
|
||||||
|
|
||||||
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 => {
|
|
||||||
if (e.key === 'Enter') submitAdobeCode(dialog);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitAdobeCode(dialog) {
|
|
||||||
const url = document.getElementById('adobe-redirect-input').value.trim();
|
|
||||||
if (!url) return;
|
|
||||||
|
|
||||||
const submitBtn = document.getElementById('adobe-dialog-submit');
|
|
||||||
const errorEl = document.getElementById('adobe-dialog-error');
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.textContent = 'Connecting…';
|
|
||||||
errorEl.textContent = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.auth.exchangeAdobe(url);
|
|
||||||
dialog.remove();
|
|
||||||
setState('auth', { ...state.auth, adobe: true });
|
|
||||||
renderAuthChips();
|
|
||||||
const { refreshTemplates } = await import('./templates.js');
|
|
||||||
refreshTemplates();
|
|
||||||
} catch (e) {
|
|
||||||
errorEl.textContent = e.data?.error || e.message || 'Connection failed.';
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.textContent = 'Connect';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toast notification ─────────────────────────────────────────────────────
|
// ── Toast notification ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function showToast(message, type = 'info') {
|
export function showToast(message, type = 'info') {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue