feat(admin): log Claude ind igen fra telefonen
Claude Codes login på værten holder 28 dage, og et langtidstoken kan ikke bruges til Remote Control, så der skal logges ind igen hver 4. uge. Hidtil krævede det en terminal for at kopiere en adresse ud og en kode ind igen. /admin/claude-login viser hvornår login udløber og har en "Start login"-knap. Værten (claude-login-agent, som spørger portalen, ikke omvendt) starter `claude auth login`, og siden viser login-adressen; koden fra Claudes side indsættes på samme side og hentes af værten et par sekunder senere. Portalen gemmer kun den korte kode, indtil den er hentet; den virker kun sammen med login-processen på værten (PKCE). Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
"""Claude-login fra telefonen: portalens halvdel af claude-login-agent.
|
||||||
|
|
||||||
|
Claude Code på værten skal logges ind igen hver 4. uge. Værten kan nås fra
|
||||||
|
portalen, men ikke omvendt, så det foregår gennem tre filer i DIR:
|
||||||
|
|
||||||
|
request portalen: "start et login" (værten sletter den, når den starter)
|
||||||
|
state.json værten: status, udløb og login-adressen mens et login venter
|
||||||
|
code portalen: koden fra Claudes side (værten henter og sletter den)
|
||||||
|
|
||||||
|
Selve login-processen kører på værten (claude-login-agent);
|
||||||
|
koden virker kun sammen med den (PKCE), så portalen gemmer ikke andet end den
|
||||||
|
korte kode, indtil værten henter den et par sekunder senere.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
DIR = config.DATA / "claude-login"
|
||||||
|
KODE = re.compile(r"^[A-Za-z0-9._~#-]{10,600}$")
|
||||||
|
# Agenten skriver state.json mindst hvert 5. minut; er den ældre end det her,
|
||||||
|
# kører agenten på værten ikke.
|
||||||
|
GAMMEL = 12 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def state() -> dict:
|
||||||
|
try:
|
||||||
|
st = json.loads((DIR / "state.json").read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
st = {"status": "ukendt"}
|
||||||
|
if (DIR / "request").exists() and st.get("status") not in ("venter", "tjekker"):
|
||||||
|
st = {**st, "status": "starter"}
|
||||||
|
if (DIR / "code").exists():
|
||||||
|
st = {**st, "status": "tjekker"}
|
||||||
|
# Mens et login venter, skriver agenten først igen når det slutter (maks 15 min).
|
||||||
|
graense = 16 * 60 if st.get("status") == "venter" else GAMMEL
|
||||||
|
st["stale"] = time.time() - st.get("updated", 0) > graense
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def request(by: str) -> None:
|
||||||
|
DIR.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
tmp = DIR / ".request.tmp"
|
||||||
|
tmp.write_text(json.dumps({"by": by, "at": int(time.time())}))
|
||||||
|
os.replace(tmp, DIR / "request")
|
||||||
|
|
||||||
|
|
||||||
|
def submit_code(kode: str) -> str | None:
|
||||||
|
"""Gem koden til værten. Returnerer en fejltekst, eller None."""
|
||||||
|
kode = "".join((kode or "").split())
|
||||||
|
if not KODE.match(kode):
|
||||||
|
return "Det ligner ikke en login-kode. Kopiér hele koden fra Claudes side."
|
||||||
|
if state().get("status") != "venter":
|
||||||
|
return "Der er ikke et login i gang. Start et nyt."
|
||||||
|
DIR.mkdir(mode=0o700, exist_ok=True)
|
||||||
|
tmp = DIR / ".code.tmp"
|
||||||
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
f.write(kode)
|
||||||
|
os.replace(tmp, DIR / "code")
|
||||||
|
return None
|
||||||
@@ -106,6 +106,8 @@ ADMIN_MODULES = [
|
|||||||
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."},
|
"desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."},
|
||||||
{"key": "secrets", "title": "Hemmeligheder til Claude", "icon": "🔏",
|
{"key": "secrets", "title": "Hemmeligheder til Claude", "icon": "🔏",
|
||||||
"desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."},
|
"desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."},
|
||||||
|
{"key": "claude-login", "title": "Claude-login", "icon": "🤖",
|
||||||
|
"desc": "Log Claude på serveren ind igen fra telefonen, uden terminal."},
|
||||||
{"key": "mcp-keys", "title": "MCP-nøgler", "icon": "🔑",
|
{"key": "mcp-keys", "title": "MCP-nøgler", "icon": "🔑",
|
||||||
"desc": "Opret og se API-nøgler til alle MCP-servere (crawlkit, Gitea, HA, Paperless, portal)."},
|
"desc": "Opret og se API-nøgler til alle MCP-servere (crawlkit, Gitea, HA, Paperless, portal)."},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import mcpkeys
|
|||||||
import mcp_server
|
import mcp_server
|
||||||
import mcp_gateway
|
import mcp_gateway
|
||||||
import secretdrop
|
import secretdrop
|
||||||
|
import claudelogin
|
||||||
|
|
||||||
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
# Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True
|
||||||
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
|
# => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated.
|
||||||
@@ -353,6 +354,34 @@ async def admin_secrets_delete(request: Request, navn: str):
|
|||||||
return RedirectResponse(f"/admin/secrets?saved={'Slettet' if ok else 'Fandtes ikke'}", status_code=303)
|
return RedirectResponse(f"/admin/secrets?saved={'Slettet' if ok else 'Fandtes ikke'}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/claude-login", response_class=HTMLResponse)
|
||||||
|
async def admin_claude_login(request: Request):
|
||||||
|
user = require_admin(request)
|
||||||
|
return templates.TemplateResponse(request, "admin_claude_login.html", {
|
||||||
|
"request": request, "user": user, "is_admin": True, "st": claudelogin.state()})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/claude-login/status")
|
||||||
|
async def admin_claude_login_status(request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
return JSONResponse(claudelogin.state(), headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/claude-login/start")
|
||||||
|
async def admin_claude_login_start(request: Request):
|
||||||
|
user = require_admin(request)
|
||||||
|
claudelogin.request((user or {}).get("preferred_username") or "?")
|
||||||
|
return JSONResponse(claudelogin.state())
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/claude-login/kode")
|
||||||
|
async def admin_claude_login_code(request: Request):
|
||||||
|
require_admin(request)
|
||||||
|
f = await request.form()
|
||||||
|
fejl = claudelogin.submit_code(f.get("kode") or "")
|
||||||
|
return JSONResponse({"error": fejl, **claudelogin.state()}, status_code=400 if fejl else 200)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin/groups", response_class=HTMLResponse)
|
@app.get("/admin/groups", response_class=HTMLResponse)
|
||||||
async def admin_groups(request: Request, saved: str = ""):
|
async def admin_groups(request: Request, saved: str = ""):
|
||||||
user = require_admin(request)
|
user = require_admin(request)
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Admin — Claude-login{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<p><a href="/admin" class="wt-muted">← Admin</a></p>
|
||||||
|
<h1>Claude-login</h1>
|
||||||
|
<p class="wt-lead">Claude på serveren skal logges ind igen hver 4. uge. Det kan gøres herfra: start et login, log ind hos Claude i en ny fane, og indsæt koden du får her. Du får en påmindelse på ntfy tre dage før.</p>
|
||||||
|
|
||||||
|
<section class="wt-card" id="cl" style="margin:1rem 0;padding:1rem 1.2rem;max-width:40rem">
|
||||||
|
<p id="cl-status" style="margin:0 0 .6rem;font-weight:600">…</p>
|
||||||
|
<p id="cl-besked" class="wt-flash" hidden></p>
|
||||||
|
|
||||||
|
<div id="cl-start" hidden>
|
||||||
|
<button class="wt-btn wt-btn--primary" type="button" id="cl-start-btn">Start login</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="cl-vent" hidden>
|
||||||
|
<p class="wt-muted" style="margin:.2rem 0">Serveren starter login-processen…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol id="cl-trin" hidden style="padding-left:1.2rem;margin:.4rem 0">
|
||||||
|
<li style="margin:.4rem 0">
|
||||||
|
<a class="wt-btn wt-btn--primary" id="cl-url" href="#" target="_blank" rel="noopener noreferrer">Log ind hos Claude</a>
|
||||||
|
<div class="wt-muted" style="font-size:.9em;margin-top:.3rem">Åbner i en ny fane. Til sidst viser Claude en kode; kopiér den.</div>
|
||||||
|
</li>
|
||||||
|
<li style="margin:.8rem 0 .4rem">
|
||||||
|
<form id="cl-form" autocomplete="off" style="margin:0">
|
||||||
|
<label style="display:block">Indsæt koden
|
||||||
|
<input class="wt-input" name="kode" id="cl-kode" required autocomplete="one-time-code" autocapitalize="off"
|
||||||
|
autocorrect="off" spellcheck="false" style="font-family:ui-monospace,monospace" placeholder="kode#…"></label>
|
||||||
|
<button class="wt-btn wt-btn--primary" type="submit" style="margin-top:.5rem">Send til serveren</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<p id="cl-tjek" class="wt-muted" hidden>Serveren logger ind…</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var TEKST = {
|
||||||
|
ok: "✅ Logget ind",
|
||||||
|
snart: "⏳ Login udløber snart",
|
||||||
|
udloebet: "⚠️ Claude er logget ud",
|
||||||
|
ukendt: "Status ukendt",
|
||||||
|
starter: "Starter login…",
|
||||||
|
venter: "Venter på din kode",
|
||||||
|
tjekker: "Logger ind…"
|
||||||
|
};
|
||||||
|
var $ = function (id) { return document.getElementById(id); };
|
||||||
|
var timer = null;
|
||||||
|
|
||||||
|
function dato(t) {
|
||||||
|
var d = new Date(t * 1000);
|
||||||
|
return d.toLocaleString("da-DK", { day: "numeric", month: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function vis(st) {
|
||||||
|
var s = st.status || "ukendt";
|
||||||
|
var linje = TEKST[s] || s;
|
||||||
|
if (st.expires && (s === "ok" || s === "snart")) {
|
||||||
|
var dage = Math.floor((st.expires * 1000 - Date.now()) / 86400000);
|
||||||
|
linje += " · udløber " + dato(st.expires) + " (om " + dage + " " + (dage === 1 ? "dag" : "dage") + ")";
|
||||||
|
} else if (st.expires && s === "udloebet") {
|
||||||
|
linje += " siden " + dato(st.expires);
|
||||||
|
}
|
||||||
|
$("cl-status").textContent = linje;
|
||||||
|
var besked = st.error || st.message;
|
||||||
|
if (st.stale) besked = "Agenten på serveren svarer ikke (claude-login-agent). Så sker der ikke noget, når du trykker.";
|
||||||
|
$("cl-besked").hidden = !besked;
|
||||||
|
$("cl-besked").textContent = besked || "";
|
||||||
|
|
||||||
|
$("cl-start").hidden = !(s === "ok" || s === "snart" || s === "udloebet" || s === "ukendt");
|
||||||
|
$("cl-start-btn").textContent = s === "ok" ? "Forny login nu" : "Start login";
|
||||||
|
$("cl-vent").hidden = s !== "starter";
|
||||||
|
$("cl-trin").hidden = s !== "venter";
|
||||||
|
$("cl-tjek").hidden = s !== "tjekker";
|
||||||
|
if (s === "venter" && st.url && $("cl-url").getAttribute("href") !== st.url) {
|
||||||
|
$("cl-url").setAttribute("href", st.url);
|
||||||
|
}
|
||||||
|
planlaeg(s === "starter" || s === "venter" || s === "tjekker" ? 2000 : 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function planlaeg(ms) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(hent, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hent() {
|
||||||
|
fetch("/admin/claude-login/status", { credentials: "same-origin", cache: "no-store" })
|
||||||
|
.then(function (r) { return r.json(); }).then(vis)
|
||||||
|
.catch(function () { planlaeg(5000); });
|
||||||
|
}
|
||||||
|
|
||||||
|
$("cl-start-btn").addEventListener("click", function () {
|
||||||
|
$("cl-start-btn").disabled = true;
|
||||||
|
fetch("/admin/claude-login/start", { method: "POST", credentials: "same-origin" })
|
||||||
|
.then(function (r) { return r.json(); }).then(vis)
|
||||||
|
.finally(function () { $("cl-start-btn").disabled = false; });
|
||||||
|
});
|
||||||
|
|
||||||
|
$("cl-form").addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var data = new FormData($("cl-form"));
|
||||||
|
fetch("/admin/claude-login/kode", { method: "POST", credentials: "same-origin", body: data })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (st) { if (!st.error) $("cl-kode").value = ""; vis(st); })
|
||||||
|
.catch(function () { planlaeg(2000); });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", function () { if (!document.hidden) hent(); });
|
||||||
|
vis({{ st | tojson }});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user