mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
feat(mcp/byok): redesign OAuth authorize page to match 2-step Connect mockup
- Step 1: L→S logos, requested access checklist, How it works box, Continue button - Step 2: API key input, Save toggle, Duration pills (1h/24h/7d/30d/until_revoked), security note - Matches screenshots: white modal on dark bg, progress dots, dark CTA buttons - Authorize handler now fetches byok_description and byok_api_key_help_url from server registry - CLAUDE.md: replace SQL snippet with proper DB migration troubleshooting guidance
This commit is contained in:
parent
37b87789e5
commit
ef1eb973b7
1 changed files with 443 additions and 35 deletions
|
|
@ -65,41 +65,439 @@ def _purge_expired_codes() -> None:
|
|||
del _byok_auth_codes[k]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML template for the authorization page
|
||||
# ---------------------------------------------------------------------------
|
||||
def _build_authorize_html(
|
||||
server_name: str,
|
||||
server_initial: str,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
code_challenge: str,
|
||||
code_challenge_method: str,
|
||||
state: str,
|
||||
server_id: str,
|
||||
access_items: list,
|
||||
help_url: str,
|
||||
) -> str:
|
||||
"""Build the 2-step BYOK OAuth authorization page HTML."""
|
||||
|
||||
_AUTHORIZE_HTML = """<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Connect to {server_name} — LiteLLM</title>
|
||||
# Build access checklist rows
|
||||
access_rows = "".join(
|
||||
f'<div class="access-item"><span class="check">✓</span>{item}</div>'
|
||||
for item in access_items
|
||||
)
|
||||
access_section = ""
|
||||
if access_rows:
|
||||
access_section = f"""
|
||||
<div class="access-box">
|
||||
<div class="access-header">
|
||||
<span class="shield">▮</span>
|
||||
<span>Requested Access</span>
|
||||
</div>
|
||||
{access_rows}
|
||||
</div>"""
|
||||
|
||||
# Help link for step 2
|
||||
help_link_html = ""
|
||||
if help_url:
|
||||
help_link_html = f'<a class="help-link" href="{help_url}" target="_blank">Where do I find my API key? ↗</a>'
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Connect {server_name} — LiteLLM</title>
|
||||
<style>
|
||||
body {{ font-family: system-ui; background: #0f172a; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }}
|
||||
.card {{ background: #1e293b; border-radius: 12px; padding: 32px; width: 400px; color: white; }}
|
||||
h2 {{ margin: 0 0 8px; font-size: 20px; }}
|
||||
p {{ color: #94a3b8; margin: 0 0 24px; font-size: 14px; }}
|
||||
label {{ font-size: 13px; color: #cbd5e1; display: block; margin-bottom: 6px; }}
|
||||
input[type=password] {{ width: 100%; padding: 10px; border-radius: 8px; border: 1px solid #334155; background: #0f172a; color: white; font-size: 14px; box-sizing: border-box; }}
|
||||
button {{ width: 100%; margin-top: 20px; padding: 12px; background: #3b82f6; border: none; border-radius: 8px; color: white; font-size: 15px; cursor: pointer; }}
|
||||
button:hover {{ background: #2563eb; }}
|
||||
.note {{ font-size: 12px; color: #64748b; margin-top: 16px; text-align: center; }}
|
||||
</style></head>
|
||||
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f172a;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}}
|
||||
.modal {{
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
padding: 36px 32px 32px;
|
||||
width: 440px;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
box-shadow: 0 25px 60px rgba(0,0,0,0.35);
|
||||
}}
|
||||
/* Progress dots */
|
||||
.dots {{
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
margin-bottom: 28px;
|
||||
}}
|
||||
.dot {{
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
}}
|
||||
.dot.active {{ background: #38bdf8; }}
|
||||
/* Close button */
|
||||
.close-btn {{
|
||||
position: absolute;
|
||||
top: 16px; right: 16px;
|
||||
background: none; border: none;
|
||||
font-size: 16px; color: #94a3b8;
|
||||
cursor: pointer; line-height: 1;
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}}
|
||||
.close-btn:hover {{ background: #f1f5f9; color: #475569; }}
|
||||
/* Logo pair */
|
||||
.logos {{
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
gap: 12px; margin-bottom: 20px;
|
||||
}}
|
||||
.logo {{
|
||||
width: 52px; height: 52px;
|
||||
border-radius: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px; font-weight: 800; color: white;
|
||||
}}
|
||||
.logo-l {{ background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%); }}
|
||||
.logo-s {{ background: linear-gradient(135deg, #818cf8 0%, #4f46e5 100%); }}
|
||||
.logo-arrow {{ color: #cbd5e1; font-size: 20px; font-weight: 300; }}
|
||||
/* Headings */
|
||||
.step-title {{
|
||||
text-align: center;
|
||||
font-size: 21px; font-weight: 700;
|
||||
color: #0f172a; margin-bottom: 8px;
|
||||
}}
|
||||
.step-subtitle {{
|
||||
text-align: center;
|
||||
font-size: 14px; color: #64748b;
|
||||
line-height: 1.55; margin-bottom: 22px;
|
||||
}}
|
||||
/* Info box */
|
||||
.info-box {{
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex; gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}}
|
||||
.info-icon {{ font-size: 17px; flex-shrink: 0; margin-top: 1px; color: #38bdf8; }}
|
||||
.info-box h4 {{ font-size: 13px; font-weight: 600; color: #1e293b; margin-bottom: 4px; }}
|
||||
.info-box p {{ font-size: 13px; color: #64748b; line-height: 1.5; }}
|
||||
/* Access checklist */
|
||||
.access-box {{
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 22px;
|
||||
}}
|
||||
.access-header {{
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
.shield {{ color: #22c55e; font-size: 15px; }}
|
||||
.access-header > span:last-child {{
|
||||
font-size: 11px; font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
}}
|
||||
.access-item {{
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
font-size: 13.5px; color: #374151;
|
||||
padding: 3px 0;
|
||||
}}
|
||||
.check {{ color: #22c55e; font-weight: 700; font-size: 13px; }}
|
||||
/* Primary CTA */
|
||||
.btn-primary {{
|
||||
width: 100%; padding: 15px;
|
||||
background: #0f172a; color: white;
|
||||
border: none; border-radius: 12px;
|
||||
font-size: 15px; font-weight: 600;
|
||||
cursor: pointer; margin-bottom: 10px;
|
||||
}}
|
||||
.btn-primary:hover {{ background: #1e293b; }}
|
||||
.btn-cancel {{
|
||||
width: 100%; padding: 8px;
|
||||
background: none; border: none;
|
||||
font-size: 13.5px; color: #94a3b8;
|
||||
cursor: pointer;
|
||||
}}
|
||||
.btn-cancel:hover {{ color: #64748b; }}
|
||||
/* Step 2 nav */
|
||||
.step2-nav {{
|
||||
display: flex; align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}}
|
||||
.back-btn {{
|
||||
background: none; border: none;
|
||||
font-size: 13.5px; color: #64748b;
|
||||
cursor: pointer; display: flex; align-items: center; gap: 4px;
|
||||
}}
|
||||
.back-btn:hover {{ color: #374151; }}
|
||||
/* Key icon */
|
||||
.key-icon-wrap {{
|
||||
width: 46px; height: 46px;
|
||||
background: #e0f2fe;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; margin-bottom: 14px;
|
||||
}}
|
||||
/* Form elements */
|
||||
.field-label {{
|
||||
font-size: 13.5px; font-weight: 600;
|
||||
color: #1e293b; display: block;
|
||||
margin-bottom: 7px;
|
||||
}}
|
||||
.key-input {{
|
||||
width: 100%; padding: 11px 13px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
font-size: 14px; color: #0f172a;
|
||||
outline: none; transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}}
|
||||
.key-input:focus {{
|
||||
border-color: #38bdf8;
|
||||
box-shadow: 0 0 0 3px rgba(56,189,248,0.12);
|
||||
}}
|
||||
.help-link {{
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
color: #0ea5e9; font-size: 13px;
|
||||
text-decoration: none; margin: 8px 0 16px;
|
||||
}}
|
||||
.help-link:hover {{ text-decoration: underline; }}
|
||||
/* Save toggle card */
|
||||
.save-card {{
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 13px 15px;
|
||||
margin-bottom: 6px;
|
||||
}}
|
||||
.save-row {{
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}}
|
||||
.save-icon {{ font-size: 16px; }}
|
||||
.save-label {{
|
||||
flex: 1;
|
||||
font-size: 14px; font-weight: 500; color: #1e293b;
|
||||
}}
|
||||
/* Toggle switch */
|
||||
.toggle {{ position: relative; width: 44px; height: 24px; flex-shrink: 0; }}
|
||||
.toggle input {{ opacity: 0; width: 0; height: 0; }}
|
||||
.slider {{
|
||||
position: absolute; inset: 0;
|
||||
background: #e2e8f0;
|
||||
border-radius: 24px; cursor: pointer;
|
||||
transition: background 0.18s;
|
||||
}}
|
||||
.slider::before {{
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 18px; height: 18px;
|
||||
left: 3px; bottom: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.18s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.18);
|
||||
}}
|
||||
input:checked + .slider {{ background: #38bdf8; }}
|
||||
input:checked + .slider::before {{ transform: translateX(20px); }}
|
||||
/* Duration pills */
|
||||
.duration-section {{ margin-top: 14px; }}
|
||||
.duration-label {{
|
||||
font-size: 12px; font-weight: 600;
|
||||
color: #64748b; margin-bottom: 8px;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}}
|
||||
.pills {{ display: flex; flex-wrap: wrap; gap: 7px; }}
|
||||
.pill {{
|
||||
padding: 6px 13px;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 20px;
|
||||
font-size: 13px; color: #475569;
|
||||
cursor: pointer; background: white;
|
||||
transition: all 0.13s;
|
||||
user-select: none;
|
||||
}}
|
||||
.pill:hover {{ border-color: #94a3b8; }}
|
||||
.pill.sel {{
|
||||
border-color: #38bdf8;
|
||||
color: #0284c7;
|
||||
background: #e0f2fe;
|
||||
}}
|
||||
/* Security note */
|
||||
.sec-note {{
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 11px 14px;
|
||||
display: flex; gap: 9px; align-items: flex-start;
|
||||
margin: 16px 0;
|
||||
}}
|
||||
.sec-icon {{ font-size: 13px; color: #94a3b8; margin-top: 1px; flex-shrink: 0; }}
|
||||
.sec-note p {{ font-size: 12.5px; color: #64748b; line-height: 1.5; }}
|
||||
/* Connect button */
|
||||
.btn-connect {{
|
||||
width: 100%; padding: 15px;
|
||||
border: none; border-radius: 12px;
|
||||
font-size: 15px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: #bae6fd; color: #0369a1;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}}
|
||||
.btn-connect.ready {{
|
||||
background: #0ea5e9; color: white;
|
||||
}}
|
||||
.btn-connect.ready:hover {{ background: #0284c7; }}
|
||||
/* Step visibility */
|
||||
.step {{ display: none; }}
|
||||
.step.show {{ display: block; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Connect to {server_name}</h2>
|
||||
<p>Enter your {server_name} API key to authorize this connection.</p>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="client_id" value="{client_id}">
|
||||
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
|
||||
<input type="hidden" name="code_challenge" value="{code_challenge}">
|
||||
<input type="hidden" name="code_challenge_method" value="{code_challenge_method}">
|
||||
<input type="hidden" name="state" value="{state}">
|
||||
<input type="hidden" name="server_id" value="{server_id}">
|
||||
<label>{server_name} API Key</label>
|
||||
<input type="password" name="api_key" placeholder="Enter your API key" required autofocus>
|
||||
<button type="submit">Connect & Authorize</button>
|
||||
</form>
|
||||
<p class="note">Your key is encrypted at rest and never shared with third parties.</p>
|
||||
<div class="modal">
|
||||
|
||||
<!-- ── STEP 1: Connect ─────────────────────────────────────── -->
|
||||
<div id="s1" class="step show">
|
||||
<div class="dots">
|
||||
<div class="dot active"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
<button class="close-btn" type="button" onclick="doCancel()" title="Close">×</button>
|
||||
|
||||
<div class="logos">
|
||||
<div class="logo logo-l">L</div>
|
||||
<span class="logo-arrow">→</span>
|
||||
<div class="logo logo-s">{server_initial}</div>
|
||||
</div>
|
||||
|
||||
<h2 class="step-title">Connect {server_name}</h2>
|
||||
<p class="step-subtitle">LiteLLM needs access to {server_name} to complete your request.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="info-icon">ⓘ</span>
|
||||
<div>
|
||||
<h4>How it works</h4>
|
||||
<p>LiteLLM acts as a secure bridge. Your requests are routed through our MCP client directly to {server_name}’s API.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{access_section}
|
||||
|
||||
<button class="btn-primary" type="button" onclick="goStep2()">
|
||||
Continue to Authentication →
|
||||
</button>
|
||||
<button class="btn-cancel" type="button" onclick="doCancel()">Cancel</button>
|
||||
</div>
|
||||
|
||||
<!-- ── STEP 2: Provide API Key ──────────────────────────────── -->
|
||||
<div id="s2" class="step">
|
||||
<div class="step2-nav">
|
||||
<button class="back-btn" type="button" onclick="goStep1()">← Back</button>
|
||||
<div class="dots">
|
||||
<div class="dot active"></div>
|
||||
<div class="dot active"></div>
|
||||
</div>
|
||||
<button class="close-btn" style="position:static;" type="button" onclick="doCancel()" title="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="key-icon-wrap">🔑</div>
|
||||
<h2 class="step-title" style="text-align:left;">Provide API Key</h2>
|
||||
<p class="step-subtitle" style="text-align:left;">Enter your {server_name} API key to authorize this connection.</p>
|
||||
|
||||
<form method="POST" id="authForm" onsubmit="prepareSubmit()">
|
||||
<input type="hidden" name="client_id" value="{client_id}">
|
||||
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
|
||||
<input type="hidden" name="code_challenge" value="{code_challenge}">
|
||||
<input type="hidden" name="code_challenge_method" value="{code_challenge_method}">
|
||||
<input type="hidden" name="state" value="{state}">
|
||||
<input type="hidden" name="server_id" value="{server_id}">
|
||||
<input type="hidden" name="duration" id="durInput" value="until_revoked">
|
||||
|
||||
<label class="field-label">{server_name} API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
name="api_key"
|
||||
id="apiKey"
|
||||
class="key-input"
|
||||
placeholder="Enter your API key"
|
||||
required
|
||||
autofocus
|
||||
oninput="syncBtn()"
|
||||
>
|
||||
|
||||
{help_link_html}
|
||||
|
||||
<div class="save-card">
|
||||
<div class="save-row">
|
||||
<span class="save-icon">💾</span>
|
||||
<span class="save-label">Save key for future use</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="saveToggle" onchange="toggleDur()">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="durSection" class="duration-section" style="display:none;">
|
||||
<div class="duration-label">Duration</div>
|
||||
<div class="pills">
|
||||
<div class="pill" onclick="selDur('1h',this)">1 hour</div>
|
||||
<div class="pill sel" onclick="selDur('24h',this)">24 hours</div>
|
||||
<div class="pill" onclick="selDur('7d',this)">7 days</div>
|
||||
<div class="pill" onclick="selDur('30d',this)">30 days</div>
|
||||
<div class="pill" onclick="selDur('until_revoked',this)">Until I revoke</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec-note">
|
||||
<span class="sec-icon">🔒</span>
|
||||
<p>Your key is encrypted at rest and transmitted securely. It is never shared with third parties.</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-connect" id="connectBtn">
|
||||
🔒 Connect & Authorize
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
function goStep2() {{
|
||||
document.getElementById('s1').classList.remove('show');
|
||||
document.getElementById('s2').classList.add('show');
|
||||
}}
|
||||
function goStep1() {{
|
||||
document.getElementById('s2').classList.remove('show');
|
||||
document.getElementById('s1').classList.add('show');
|
||||
}}
|
||||
function doCancel() {{
|
||||
if (window.opener) window.close();
|
||||
else window.history.back();
|
||||
}}
|
||||
function toggleDur() {{
|
||||
const on = document.getElementById('saveToggle').checked;
|
||||
document.getElementById('durSection').style.display = on ? 'block' : 'none';
|
||||
}}
|
||||
function selDur(val, el) {{
|
||||
document.querySelectorAll('.pill').forEach(p => p.classList.remove('sel'));
|
||||
el.classList.add('sel');
|
||||
document.getElementById('durInput').value = val;
|
||||
}}
|
||||
function syncBtn() {{
|
||||
const btn = document.getElementById('connectBtn');
|
||||
if (document.getElementById('apiKey').value.length > 0) {{
|
||||
btn.classList.add('ready');
|
||||
}} else {{
|
||||
btn.classList.remove('ready');
|
||||
}}
|
||||
}}
|
||||
function prepareSubmit() {{
|
||||
// nothing extra needed — duration is already in the hidden input
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
|
@ -166,8 +564,10 @@ async def byok_authorize_get(
|
|||
if not code_challenge:
|
||||
raise HTTPException(status_code=400, detail="code_challenge is required")
|
||||
|
||||
# Resolve a human-readable server name.
|
||||
server_name = server_id or "MCP Server"
|
||||
# Resolve server metadata (name, description items, help URL).
|
||||
server_name = "MCP Server"
|
||||
access_items: list = []
|
||||
help_url = ""
|
||||
if server_id:
|
||||
try:
|
||||
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
|
||||
|
|
@ -176,18 +576,26 @@ async def byok_authorize_get(
|
|||
|
||||
registry = global_mcp_server_manager.get_registry()
|
||||
if server_id in registry:
|
||||
server_name = registry[server_id].server_name or registry[server_id].name
|
||||
srv = registry[server_id]
|
||||
server_name = srv.server_name or srv.name
|
||||
access_items = list(srv.byok_description or [])
|
||||
help_url = srv.byok_api_key_help_url or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
html = _AUTHORIZE_HTML.format(
|
||||
server_initial = (server_name[0].upper()) if server_name else "S"
|
||||
|
||||
html = _build_authorize_html(
|
||||
server_name=server_name,
|
||||
server_initial=server_initial,
|
||||
client_id=client_id or "",
|
||||
redirect_uri=redirect_uri,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method=code_challenge_method or "S256",
|
||||
state=state or "",
|
||||
server_id=server_id or "",
|
||||
access_items=access_items,
|
||||
help_url=help_url,
|
||||
)
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue