Files
weircon-front/templates/admin_mcp_keys.html
T
agw 9675a5b76b feat(mcp): eksponér YouTube-offline i portalens MCP + importér MCP-laget i repoet
Fem nye værktøjer i portal-navnerummet: yt_status, yt_download, yt_watch,
yt_unwatch, yt_check_subs. Gatewayen monterer mcp_server under namespace
"portal", så de arver portal_-præfikset og de scopes nøglerne allerede har —
ingen ekstra registrering.

Download og abonnement-tjek returnerer med det samme og arbejder videre i
baggrunden, så et MCP-kald ikke blokerer i minutter på en lang video.

Importerer samtidig mcp_server.py, mcp_gateway.py, mcpkeys.py og
templates/admin_mcp_keys.html, som kun har eksisteret i prod (CT151) og aldrig
i repoet.

ÅRSAG TIL IMPORTEN: den manglende viden i repoet kostede nedetid. Da jeg
udrullede repoets main.py og config.py til prod i går, overskrev jeg
MCP-monteringen (mcp_app.lifespan + route/middleware-overførsel), hele
/admin/mcp-keys-modulet og konstanterne MCP_REGISTRY/MCP_SELF_URL.
weircon.dk/mcp svarede 404 i ~13 timer. Rettet ved at genopbygge begge filer
fra prod-backuppen plus YouTube-ændringerne, ikke omvendt.

Repo og prod er nu identiske bortset fra static/portal.css og
templates/admin.html, hvor repoet er FORAN (wt-*-temaklasser, aldrig udrullet).

Claude-Session: https://claude.ai/code/session_011WxG8pbLj61jYU1MMaD7VC
2026-09-20 09:20:35 +02:00

186 lines
9.7 KiB
HTML

{% extends "base.html" %}
{% block title %}Admin — MCP-nøgler{% endblock %}
{% block body %}
<h1>🔑 MCP-nøgler</h1>
<p class="wt-lead">Ét sted at oprette og se API-nøgler til alle MCP-servere. Kopiér en
<code>claude mcp add</code>-kommando og kør den på den maskine hvor Claude kører.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
<h2 style="margin-top:1.5rem">Eksterne MCP-servere</h2>
<table class="wt-table">
<thead><tr><th>Navn</th><th>Transport</th><th>URL / kommando</th><th>Status</th><th>Token</th><th></th></tr></thead>
<tbody>
{% for e in external %}
<tr>
<td><strong>{{ e.label }}</strong><br><small>{{ e.id }}</small></td>
<td>{{ e.transport }}</td>
<td>
{% if e.url %}<div><small>{{ e.url }}</small></div>{% endif %}
<code class="cmd" style="display:block;word-break:break-all;margin-top:.3rem">{{ e.command }}</code>
<button type="button" class="wt-btn" onclick="cp(this)">Kopiér kommando</button>
</td>
<td>
{% if e.last_check %}
{% if e.last_check.ok %}<span style="color:var(--ok,green)">●</span>{% elif e.last_check.ok == false %}<span style="color:var(--err,crimson)">●</span>{% else %}<span>○</span>{% endif %}
<small>{{ e.last_check.detail }}</small>
{% else %}<small>—</small>{% endif %}
<form method="post" style="margin-top:.3rem"><input type="hidden" name="action" value="healthcheck"><input type="hidden" name="id" value="{{ e.id }}"><button class="wt-btn" type="submit">Tjek</button></form>
</td>
<td><small>…{{ e.token[-4:] if e.token else "" }}</small></td>
<td>
<form method="post" onsubmit="return confirm('Slet {{ e.label }}?')"><input type="hidden" name="action" value="delete_external"><input type="hidden" name="id" value="{{ e.id }}"><button class="wt-btn" type="submit">✕</button></form>
</td>
</tr>
{% else %}
<tr><td colspan="6"><small>Ingen endnu.</small></td></tr>
{% endfor %}
</tbody>
</table>
<h3 style="margin-top:1.2rem">Tilføj / rediger ekstern MCP</h3>
<form method="post" class="wt-card" style="max-width:680px">
<input type="hidden" name="action" value="save_external">
<p><label>ID (kort, fx <code>gitea</code>)<br><input name="id" required></label></p>
<p><label>Navn<br><input name="label"></label></p>
<p><label>Transport<br>
<select name="transport"><option value="http">http</option><option value="sse">sse</option><option value="stdio">stdio</option></select></label></p>
<p><label>URL (http/sse)<br><input name="url" placeholder="https://…/mcp"></label></p>
<p><label>Token<br><input name="token"></label></p>
<p><label>Auth-header (default Authorization)<br><input name="auth_header" placeholder="Authorization"></label></p>
<p><label>Kommando (kun stdio, fx <code>npx -y paperless-mcp …</code>)<br><input name="command"></label></p>
<p><label>Note<br><input name="notes"></label></p>
<p><button class="wt-btn wt-btn--primary" type="submit">Gem</button></p>
</form>
<h2 style="margin-top:2rem">Gateway — alle MCP'er bag ét token</h2>
<p class="wt-lead"><code>{{ self_url }}</code> føderer portalens egne værktøjer + alle eksterne MCP'er ovenfor. Opret ét token (fx "remote-desktop") og giv en remote Claude den ene kommando — så har den adgang til alt bagved. <strong>Genstart portalen efter ændringer i listen ovenfor</strong> for at de slår igennem i gatewayen.</p>
{% if new_token %}
<div class="wt-flash" style="border-left:4px solid var(--ok,green)">
<strong>Nyt token "{{ new_token.name }}" — kopiér det nu, det vises kun én gang.</strong>
<code class="cmd" style="display:block;word-break:break-all;margin:.4rem 0">{{ new_token.token }}</code>
<div><small>Færdig kommando:</small></div>
<code class="cmd" style="display:block;word-break:break-all;margin-top:.2rem">{{ new_token.command }}</code>
<button type="button" class="wt-btn" onclick="cp(this)">Kopiér kommando</button>
</div>
{% endif %}
<form method="post" class="wt-card" style="margin-bottom:1rem;max-width:760px">
<input type="hidden" name="action" value="mint_portal">
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<input name="name" placeholder="fx remote-desktop" required>
<button class="wt-btn wt-btn--primary" type="submit">Opret token</button>
</div>
<div style="margin-top:.7rem">
<small><strong>Adgang til:</strong></small><br>
{% for ns in namespaces %}
<label style="margin-right:.9rem;white-space:nowrap"><input type="checkbox" name="allowed" value="{{ ns }}"> {{ ns }}</label>
{% endfor %}
<div style="margin-top:.3rem"><small>Uden markering får nøglen ingen adgang til noget.</small></div>
</div>
</form>
<table class="wt-table">
<thead><tr><th>Navn</th><th>Adgang</th><th>Oprettet</th><th>Sidst brugt</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for t in portal_tokens %}
<tr>
<td>{{ t.name }}</td>
<td>
{% if t.revoked %}<small>—</small>{% else %}
<form method="post" style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<input type="hidden" name="action" value="set_allowed">
<input type="hidden" name="name" value="{{ t.name }}">
{% for ns in namespaces %}
<label style="white-space:nowrap"><input type="checkbox" name="allowed" value="{{ ns }}"{% if ns in t.allowed %} checked{% endif %}> {{ ns }}</label>
{% endfor %}
<button class="wt-btn" type="submit">Gem</button>
</form>
{% endif %}
</td>
<td><small>{{ t.created_at }}</small></td>
<td><small>{{ t.last_used_at or "—" }}</small></td>
<td>{% if t.revoked %}<span style="color:var(--err,crimson)">spærret</span>{% else %}<span style="color:var(--ok,green)">aktiv</span>{% endif %}</td>
<td>{% if not t.revoked %}<form method="post" onsubmit="return confirm('Spær {{ t.name }}?')"><input type="hidden" name="action" value="revoke_portal"><input type="hidden" name="name" value="{{ t.name }}"><button class="wt-btn" type="submit">Spær</button></form>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="6"><small>Ingen tokens endnu.</small></td></tr>
{% endfor %}
</tbody>
</table>
<p><small>Tokens vises kun ved oprettelse og gemmes kun som hash — mistet token spærres og et nyt oprettes.</small></p>
<h3 style="margin-top:2rem">Authentik-brugere</h3>
<p><small>Connectors i Claude-appen logger ind som en <strong>Authentik-bruger</strong> og har ikke et token her,
så deres adgang sættes på identitet i stedet. Brugere uden egen linje får standarden.</small></p>
<form method="post" class="wt-card" style="max-width:760px;margin-bottom:1rem">
<input type="hidden" name="action" value="set_oauth_default">
<div><small><strong>Standard</strong> — gælder Authentik-brugere uden egen linje</small></div>
<div style="margin:.35rem 0 .7rem">
{% for ns in namespaces %}
<label style="margin-right:.9rem;white-space:nowrap"><input type="checkbox" name="default" value="{{ ns }}"{% if ns in oauth_access.default %} checked{% endif %}> {{ ns }}</label>
{% endfor %}
</div>
<button class="wt-btn" type="submit">Gem standard</button>
</form>
<table class="wt-table">
<thead><tr><th>Bruger</th><th>Adgang</th><th></th></tr></thead>
<tbody>
{% for u in oauth_rows %}
<tr>
<td>{{ u.label }}</td>
<td>
<form method="post" style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<input type="hidden" name="action" value="set_oauth_user">
<input type="hidden" name="name" value="{{ u.username }}">
{% for ns in namespaces %}
<label style="white-space:nowrap"><input type="checkbox" name="allowed" value="{{ ns }}"{% if ns in u.allowed %} checked{% endif %}> {{ ns }}</label>
{% endfor %}
<button class="wt-btn" type="submit">Gem</button>
</form>
</td>
<td>
<form method="post" onsubmit="return confirm('Fjern egen adgang for {{ u.username }}? Brugeren falder tilbage til standarden.')">
<input type="hidden" name="action" value="del_oauth_user">
<input type="hidden" name="name" value="{{ u.username }}">
<button class="wt-btn" type="submit">✕</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="3"><small>Ingen brugere med egen adgang — alle bruger standarden.</small></td></tr>
{% endfor %}
</tbody>
</table>
<form method="post" class="wt-card" style="max-width:760px;margin-top:1rem">
<input type="hidden" name="action" value="set_oauth_user">
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<select name="name" required>
<option value="">— vælg Authentik-bruger —</option>
{% for u in ak_users_available %}<option value="{{ u.username }}">{{ u.label }}</option>{% endfor %}
</select>
<button class="wt-btn wt-btn--primary" type="submit">Tilføj</button>
</div>
<div style="margin-top:.6rem">
<small><strong>Adgang til:</strong></small><br>
{% for ns in namespaces %}
<label style="margin-right:.9rem;white-space:nowrap"><input type="checkbox" name="allowed" value="{{ ns }}"> {{ ns }}</label>
{% endfor %}
</div>
{% if not ak_users_available %}<div style="margin-top:.4rem"><small>Alle Authentik-brugere har allerede en egen linje (eller Authentik svarer ikke).</small></div>{% endif %}
</form>
<script>
function cp(btn){
var code = btn.previousElementSibling;
while (code && !code.classList.contains("cmd")) { code = code.previousElementSibling; }
if (!code) return;
navigator.clipboard.writeText(code.textContent.trim()).then(function(){
var o = btn.textContent; btn.textContent = "Kopieret ✓"; setTimeout(function(){btn.textContent=o;}, 1200);
});
}
</script>
{% endblock %}