fix: use existing Adobe Sign redirect URI (localhost:8080) in web UI
The Adobe Sign OAuth app has https://localhost:8080/callback registered (same as CLI). The web UI now uses the same manual paste flow: - GET /api/auth/adobe/url returns the auth URL for the frontend to open - POST /api/auth/adobe/exchange accepts the full redirect URL the user copies after authorizing, extracts the code, exchanges for tokens - Dialog UI guides user through the 3-step process DocuSign keeps its standard redirect callback flow unchanged. 31/31 tests passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f78a50282a
commit
1383586d91
|
|
@ -11,6 +11,7 @@ import httpx
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from web.app import app
|
from web.app import app
|
||||||
|
from web.routers.auth import _ADOBE_TOKEN_URL
|
||||||
|
|
||||||
client = TestClient(app, raise_server_exceptions=True)
|
client = TestClient(app, raise_server_exceptions=True)
|
||||||
|
|
||||||
|
|
@ -24,29 +25,51 @@ def test_status_unauthenticated():
|
||||||
assert data["docusign"] is False
|
assert data["docusign"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_adobe_url_returns_auth_url():
|
||||||
|
"""GET /api/auth/adobe/url returns an Adobe Sign authorization URL."""
|
||||||
|
resp = client.get("/api/auth/adobe/url")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "url" in data
|
||||||
|
assert "adobesign.com" in data["url"]
|
||||||
|
assert "response_type=code" in data["url"]
|
||||||
|
# Must use the registered redirect URI
|
||||||
|
assert "localhost%3A8080" in data["url"] or "localhost:8080" in data["url"]
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_adobe_callback_stores_token():
|
def test_adobe_exchange_stores_token():
|
||||||
"""Successful Adobe OAuth callback → session has adobe_access_token."""
|
"""POST /api/auth/adobe/exchange with a valid redirect URL → session connected."""
|
||||||
respx.post("https://api.eu2.adobesign.com/oauth/v2/token").mock(
|
respx.post(_ADOBE_TOKEN_URL).mock(
|
||||||
return_value=httpx.Response(200, json={
|
return_value=httpx.Response(200, json={
|
||||||
"access_token": "adobe-test-token",
|
"access_token": "adobe-test-token",
|
||||||
"refresh_token": "adobe-refresh",
|
"refresh_token": "adobe-refresh",
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
resp = client.get("/api/auth/adobe/callback?code=authcode123", follow_redirects=False)
|
resp = client.post(
|
||||||
# Should redirect to /
|
"/api/auth/adobe/exchange",
|
||||||
assert resp.status_code in (302, 307)
|
json={"redirect_url": "https://localhost:8080/callback?code=authcode123&api_access_point=https://api.eu2.adobesign.com/"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["connected"] is True
|
||||||
|
|
||||||
# Session cookie should now contain the token
|
|
||||||
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
|
||||||
|
|
||||||
# Follow up with status check using the same session cookie
|
|
||||||
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()["adobe"] is True
|
assert status_resp.json()["adobe"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_adobe_exchange_rejects_missing_code():
|
||||||
|
"""POST /api/auth/adobe/exchange with no code in URL → 400."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/adobe/exchange",
|
||||||
|
json={"redirect_url": "https://localhost:8080/callback?error=access_denied"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
def test_docusign_callback_stores_token():
|
def test_docusign_callback_stores_token():
|
||||||
"""Successful DocuSign OAuth callback → session has docusign_access_token."""
|
"""Successful DocuSign OAuth callback → session has docusign_access_token."""
|
||||||
|
|
@ -71,22 +94,23 @@ def test_docusign_callback_stores_token():
|
||||||
@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."""
|
||||||
# First connect Adobe
|
respx.post(_ADOBE_TOKEN_URL).mock(
|
||||||
respx.post("https://api.eu2.adobesign.com/oauth/v2/token").mock(
|
|
||||||
return_value=httpx.Response(200, json={"access_token": "tok", "refresh_token": "ref"})
|
return_value=httpx.Response(200, json={"access_token": "tok", "refresh_token": "ref"})
|
||||||
)
|
)
|
||||||
connect_resp = client.get("/api/auth/adobe/callback?code=abc", follow_redirects=False)
|
|
||||||
|
# Connect Adobe via exchange
|
||||||
|
connect_resp = client.post(
|
||||||
|
"/api/auth/adobe/exchange",
|
||||||
|
json={"redirect_url": "https://localhost:8080/callback?code=abc"},
|
||||||
|
)
|
||||||
session_cookie = connect_resp.cookies["migrator_session"]
|
session_cookie = connect_resp.cookies["migrator_session"]
|
||||||
|
|
||||||
# Verify connected
|
|
||||||
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()["adobe"] is True
|
assert status_resp.json()["adobe"] is True
|
||||||
|
|
||||||
# Disconnect
|
|
||||||
disc_resp = client.get("/api/auth/adobe/disconnect", cookies={"migrator_session": session_cookie})
|
disc_resp = client.get("/api/auth/adobe/disconnect", cookies={"migrator_session": session_cookie})
|
||||||
assert disc_resp.status_code == 200
|
assert disc_resp.status_code == 200
|
||||||
new_cookie = disc_resp.cookies.get("migrator_session", session_cookie)
|
new_cookie = disc_resp.cookies.get("migrator_session", session_cookie)
|
||||||
|
|
||||||
# Verify disconnected
|
|
||||||
status_resp2 = client.get("/api/auth/status", cookies={"migrator_session": new_cookie})
|
status_resp2 = client.get("/api/auth/status", cookies={"migrator_session": new_cookie})
|
||||||
assert status_resp2.json()["adobe"] is False
|
assert status_resp2.json()["adobe"] is False
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,33 @@ web/routers/auth.py
|
||||||
-------------------
|
-------------------
|
||||||
OAuth endpoints for Adobe Sign and DocuSign.
|
OAuth endpoints for Adobe Sign and DocuSign.
|
||||||
|
|
||||||
Adobe Sign: Authorization Code flow
|
Adobe Sign uses the same redirect URI as the CLI (https://localhost:8080/callback).
|
||||||
DocuSign: Authorization Code flow (demo sandbox)
|
Since nothing runs on that port, the browser lands on a failed page. The user copies
|
||||||
|
the URL and submits it via POST /api/auth/adobe/exchange — identical to the CLI flow.
|
||||||
|
|
||||||
|
DocuSign uses a standard redirect callback handled directly by this server.
|
||||||
|
|
||||||
Tokens are stored in a signed session cookie.
|
Tokens are stored in a signed session cookie.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse, RedirectResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
from web.session import get_session, save_session, clear_session
|
from web.session import get_session, save_session
|
||||||
|
|
||||||
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_TOKEN_URL = "https://api.eu2.adobesign.com/oauth/v2/token"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Status
|
# Status
|
||||||
|
|
@ -34,49 +46,73 @@ def auth_status(request: Request):
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Adobe Sign
|
# Adobe Sign — manual paste flow (matches CLI behaviour)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/adobe/start")
|
@router.get("/adobe/url")
|
||||||
def adobe_start():
|
def adobe_auth_url():
|
||||||
"""Redirect the browser to the Adobe Sign OAuth authorization page."""
|
"""
|
||||||
|
Return the Adobe Sign authorization URL for the frontend to open in a new tab.
|
||||||
|
The user authorizes, lands on a failed page (nothing runs on :8080), copies
|
||||||
|
the URL, and submits it to POST /api/auth/adobe/exchange.
|
||||||
|
"""
|
||||||
params = (
|
params = (
|
||||||
f"?response_type=code"
|
f"?response_type=code"
|
||||||
f"&client_id={settings.adobe_client_id}"
|
f"&client_id={settings.adobe_client_id}"
|
||||||
f"&redirect_uri={settings.adobe_redirect_uri}"
|
f"&redirect_uri={_ADOBE_REDIRECT_URI}"
|
||||||
f"&scope=library_read:self+library_write:self+user_read:self"
|
f"&scope=library_read:self+library_write:self+user_read:self"
|
||||||
)
|
)
|
||||||
auth_url = "https://secure.eu2.adobesign.com/public/oauth/v2" + params
|
return {"url": _ADOBE_AUTH_URL + params}
|
||||||
return RedirectResponse(auth_url)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/adobe/callback")
|
class AdobeExchangeRequest(BaseModel):
|
||||||
async def adobe_callback(request: Request, code: str = ""):
|
redirect_url: str
|
||||||
"""Exchange authorization code for access + refresh tokens."""
|
|
||||||
if not code:
|
|
||||||
return JSONResponse({"error": "missing code"}, status_code=400)
|
@router.post("/adobe/exchange")
|
||||||
|
async def adobe_exchange(body: AdobeExchangeRequest, request: Request):
|
||||||
|
"""
|
||||||
|
Accept the full redirect URL that the user copied after Adobe Sign authorization
|
||||||
|
(e.g. https://localhost:8080/callback?code=...&api_access_point=...).
|
||||||
|
Extract the code and exchange it for tokens.
|
||||||
|
"""
|
||||||
|
parsed = urlparse(body.redirect_url)
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
|
||||||
|
if "error" in params:
|
||||||
|
error = params.get("error_description", params.get("error", ["unknown"]))[0]
|
||||||
|
return JSONResponse({"error": f"Adobe Sign returned error: {error}"}, status_code=400)
|
||||||
|
|
||||||
|
code_list = params.get("code")
|
||||||
|
if not code_list:
|
||||||
|
return JSONResponse({"error": "No code found in the URL. Did you paste the correct redirect URL?"}, status_code=400)
|
||||||
|
|
||||||
|
code = code_list[0]
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"https://api.eu2.adobesign.com/oauth/v2/token",
|
_ADOBE_TOKEN_URL,
|
||||||
data={
|
data={
|
||||||
"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": settings.adobe_redirect_uri,
|
"redirect_uri": _ADOBE_REDIRECT_URI,
|
||||||
"code": code,
|
"code": code,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if not resp.is_success:
|
if not resp.is_success:
|
||||||
return JSONResponse({"error": "token exchange failed", "detail": resp.text}, status_code=502)
|
return JSONResponse({"error": "Token exchange failed", "detail": resp.text}, status_code=502)
|
||||||
|
|
||||||
token_data = resp.json()
|
token_data = resp.json()
|
||||||
|
if "error" in token_data:
|
||||||
|
return JSONResponse({"error": token_data.get("error_description", token_data["error"])}, status_code=400)
|
||||||
|
|
||||||
session = get_session(request)
|
session = get_session(request)
|
||||||
session["adobe_access_token"] = token_data.get("access_token")
|
session["adobe_access_token"] = token_data.get("access_token")
|
||||||
session["adobe_refresh_token"] = token_data.get("refresh_token")
|
session["adobe_refresh_token"] = token_data.get("refresh_token")
|
||||||
|
|
||||||
response = RedirectResponse("/")
|
response = JSONResponse({"connected": True})
|
||||||
save_session(response, session)
|
save_session(response, session)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
@ -92,7 +128,7 @@ def adobe_disconnect(request: Request):
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# DocuSign
|
# DocuSign — standard redirect callback
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/docusign/start")
|
@router.get("/docusign/start")
|
||||||
|
|
@ -110,11 +146,10 @@ def docusign_start():
|
||||||
|
|
||||||
@router.get("/docusign/callback")
|
@router.get("/docusign/callback")
|
||||||
async def docusign_callback(request: Request, code: str = ""):
|
async def docusign_callback(request: Request, code: str = ""):
|
||||||
"""Exchange authorization code for access token."""
|
"""Exchange DocuSign authorization code for access token."""
|
||||||
if not code:
|
if not code:
|
||||||
return JSONResponse({"error": "missing code"}, status_code=400)
|
return JSONResponse({"error": "missing code"}, status_code=400)
|
||||||
|
|
||||||
import base64
|
|
||||||
credentials = base64.b64encode(
|
credentials = base64.b64encode(
|
||||||
f"{settings.docusign_client_id}:{settings.docusign_client_secret}".encode()
|
f"{settings.docusign_client_id}:{settings.docusign_client_secret}".encode()
|
||||||
).decode()
|
).decode()
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,8 @@
|
||||||
|
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
let adobeTemplates = []; // [{id, name, modifiedDate}]
|
|
||||||
let dsTemplates = []; // [{id, name, lastModified}]
|
|
||||||
let statusTemplates = []; // [{adobe_id, name, status, docusign_id, ...}]
|
let statusTemplates = []; // [{adobe_id, name, status, docusign_id, ...}]
|
||||||
|
let dsTemplates = []; // [{id, name, lastModified}]
|
||||||
let authState = { adobe: false, docusign: false };
|
let authState = { adobe: false, docusign: false };
|
||||||
|
|
||||||
// ── Init ────────────────────────────────────────────────────────────────────
|
// ── Init ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -31,21 +30,110 @@ async function refreshAuth() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAuthBar() {
|
function renderAuthBar() {
|
||||||
renderAuthBadge('badge-adobe', 'Adobe Sign', authState.adobe, '/api/auth/adobe/start', '/api/auth/adobe/disconnect');
|
// Adobe: manual paste flow
|
||||||
renderAuthBadge('badge-docusign', 'DocuSign', authState.docusign, '/api/auth/docusign/start', '/api/auth/docusign/disconnect');
|
const adobeEl = $('badge-adobe');
|
||||||
|
adobeEl.textContent = authState.adobe ? '✓ Adobe Sign' : 'Connect Adobe Sign';
|
||||||
|
adobeEl.className = 'auth-badge' + (authState.adobe ? ' connected' : '');
|
||||||
|
adobeEl.onclick = authState.adobe
|
||||||
|
? () => disconnectPlatform('adobe')
|
||||||
|
: () => startAdobeAuth();
|
||||||
|
|
||||||
|
// DocuSign: standard redirect
|
||||||
|
const dsEl = $('badge-docusign');
|
||||||
|
dsEl.textContent = authState.docusign ? '✓ DocuSign' : 'Connect DocuSign';
|
||||||
|
dsEl.className = 'auth-badge' + (authState.docusign ? ' connected' : '');
|
||||||
|
dsEl.onclick = authState.docusign
|
||||||
|
? () => disconnectPlatform('docusign')
|
||||||
|
: () => { window.location.href = '/api/auth/docusign/start'; };
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAuthBadge(id, label, connected, connectUrl, disconnectUrl) {
|
async function disconnectPlatform(platform) {
|
||||||
const el = $(id);
|
await fetch(`/api/auth/${platform}/disconnect`);
|
||||||
el.textContent = connected ? `✓ ${label}` : `Connect ${label}`;
|
authState[platform] = false;
|
||||||
el.className = 'auth-badge' + (connected ? ' connected' : '');
|
renderAuthBar();
|
||||||
el.onclick = () => {
|
await refreshTemplates();
|
||||||
if (connected) {
|
}
|
||||||
fetch(disconnectUrl).then(() => { authState[id.replace('badge-','')] = false; renderAuthBar(); refreshTemplates(); });
|
|
||||||
} else {
|
// Adobe Sign uses the same manual-paste flow as the CLI:
|
||||||
window.location.href = connectUrl;
|
// 1. Open auth URL in new tab
|
||||||
|
// 2. User authorizes → lands on failed https://localhost:8080/callback page
|
||||||
|
// 3. User copies that URL, pastes it into the dialog here
|
||||||
|
// 4. We POST it to /api/auth/adobe/exchange
|
||||||
|
|
||||||
|
async function startAdobeAuth() {
|
||||||
|
const resp = await fetch('/api/auth/adobe/url');
|
||||||
|
const { url } = await resp.json();
|
||||||
|
|
||||||
|
showAdobeDialog(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAdobeDialog(authUrl) {
|
||||||
|
// Remove any existing dialog
|
||||||
|
const existing = $('adobe-auth-dialog');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const dialog = document.createElement('div');
|
||||||
|
dialog.id = 'adobe-auth-dialog';
|
||||||
|
dialog.innerHTML = `
|
||||||
|
<div class="dialog-backdrop"></div>
|
||||||
|
<div class="dialog-box">
|
||||||
|
<h2>Connect Adobe Sign</h2>
|
||||||
|
<ol>
|
||||||
|
<li><a href="${escHtml(authUrl)}" target="_blank" rel="noopener" id="adobe-auth-link">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" placeholder="https://localhost:8080/callback?code=…" />
|
||||||
|
<div class="dialog-error" id="dialog-error"></div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button id="btn-submit-code">Connect</button>
|
||||||
|
<button id="btn-cancel-dialog" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
|
$('btn-cancel-dialog').onclick = () => dialog.remove();
|
||||||
|
$('btn-submit-code').onclick = () => submitAdobeCode(dialog);
|
||||||
|
|
||||||
|
// Also handle Enter key
|
||||||
|
$('adobe-redirect-input').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') submitAdobeCode(dialog);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAdobeCode(dialog) {
|
||||||
|
const url = $('adobe-redirect-input').value.trim();
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
$('btn-submit-code').disabled = true;
|
||||||
|
$('btn-submit-code').textContent = 'Connecting…';
|
||||||
|
$('dialog-error').textContent = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/auth/adobe/exchange', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ redirect_url: url }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (!resp.ok || data.error) {
|
||||||
|
$('dialog-error').textContent = data.error || 'Connection failed.';
|
||||||
|
$('btn-submit-code').disabled = false;
|
||||||
|
$('btn-submit-code').textContent = 'Connect';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
dialog.remove();
|
||||||
|
authState.adobe = true;
|
||||||
|
renderAuthBar();
|
||||||
|
await refreshTemplates();
|
||||||
|
} catch (e) {
|
||||||
|
$('dialog-error').textContent = 'Error: ' + e.message;
|
||||||
|
$('btn-submit-code').disabled = false;
|
||||||
|
$('btn-submit-code').textContent = 'Connect';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Templates ────────────────────────────────────────────────────────────────
|
// ── Templates ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -70,7 +158,7 @@ async function refreshTemplates() {
|
||||||
fetch('/api/templates/docusign'),
|
fetch('/api/templates/docusign'),
|
||||||
]);
|
]);
|
||||||
statusTemplates = (await statusResp.json()).templates || [];
|
statusTemplates = (await statusResp.json()).templates || [];
|
||||||
dsTemplates = ((await dsResp.json()).templates || []);
|
dsTemplates = (await dsResp.json()).templates || [];
|
||||||
renderAdobeList(statusTemplates);
|
renderAdobeList(statusTemplates);
|
||||||
renderDsList(dsTemplates);
|
renderDsList(dsTemplates);
|
||||||
setStatus(`${statusTemplates.length} Adobe template(s) loaded.`);
|
setStatus(`${statusTemplates.length} Adobe template(s) loaded.`);
|
||||||
|
|
@ -138,7 +226,6 @@ async function onMigrate() {
|
||||||
$('btn-migrate').disabled = true;
|
$('btn-migrate').disabled = true;
|
||||||
setStatus(`Migrating ${ids.length} template(s)…`);
|
setStatus(`Migrating ${ids.length} template(s)…`);
|
||||||
|
|
||||||
// Show spinners
|
|
||||||
ids.forEach(id => {
|
ids.forEach(id => {
|
||||||
const spin = $('spin-' + id);
|
const spin = $('spin-' + id);
|
||||||
if (spin) spin.textContent = '⏳';
|
if (spin) spin.textContent = '⏳';
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,39 @@ button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
.empty-msg { padding: 20px; text-align: center; color: #999; font-size: 13px; }
|
.empty-msg { padding: 20px; text-align: center; color: #999; font-size: 13px; }
|
||||||
|
|
||||||
|
/* ── Adobe auth dialog ── */
|
||||||
|
.dialog-backdrop {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0,0,0,0.4);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.dialog-box {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%; left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 28px 32px;
|
||||||
|
width: min(500px, 90vw);
|
||||||
|
z-index: 101;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
.dialog-box h2 { font-size: 16px; margin-bottom: 16px; }
|
||||||
|
.dialog-box ol { padding-left: 20px; margin-bottom: 16px; line-height: 1.7; }
|
||||||
|
.dialog-box ol a { color: #1a3c5e; }
|
||||||
|
.dialog-box input[type=text] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.dialog-error { color: #c00; font-size: 12px; min-height: 18px; margin-bottom: 10px; }
|
||||||
|
.dialog-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.btn-secondary { background: #e9ecef; color: #333; }
|
||||||
|
|
||||||
/* ── Responsive ── */
|
/* ── Responsive ── */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.panel-row { grid-template-columns: 1fr; }
|
.panel-row { grid-template-columns: 1fr; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue