Add web admin page to manage agents
GET /admin serves a self-contained HTML page (ADMIN_API_KEY protected) that lists agents and lets you add/update/remove them via the admin API — no redeploy needed to add an agent.
This commit is contained in:
@@ -145,6 +145,18 @@ defmodule N8nOpenaiAdapter.Router do
|
||||
|
||||
# --- Admin API (separate ADMIN_API_KEY) ---
|
||||
|
||||
get "/admin" do
|
||||
case authorize_admin!(conn) do
|
||||
{:halt, conn} ->
|
||||
conn
|
||||
|
||||
conn ->
|
||||
conn
|
||||
|> put_resp_content_type("text/html")
|
||||
|> send_resp(200, admin_page())
|
||||
end
|
||||
end
|
||||
|
||||
get "/admin/agents" do
|
||||
case authorize_admin!(conn) do
|
||||
{:halt, conn} ->
|
||||
@@ -255,4 +267,111 @@ defmodule N8nOpenaiAdapter.Router do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Self-contained admin page: lists agents and lets you add/remove them via
|
||||
# the admin API. Uses the ADMIN_API_KEY (entered in the page) as the Bearer
|
||||
# token for the /admin/agents calls.
|
||||
defp admin_page do
|
||||
"""
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>n8n OpenAI Adapter — Agents</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
|
||||
h1 { font-size: 1.4rem; }
|
||||
input[type=text], input[type=password] { width: 100%; padding: .5rem; margin: .25rem 0 .75rem; box-sizing: border-box; }
|
||||
button { padding: .5rem 1rem; cursor: pointer; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||
th, td { text-align: left; padding: .5rem; border-bottom: 1px solid #ddd; }
|
||||
.msg { margin-top: 1rem; padding: .5rem; border-radius: 4px; }
|
||||
.ok { background: #e6f4ea; color: #1e7e34; }
|
||||
.err { background: #fdecea; color: #c62828; }
|
||||
.del { color: #c62828; background: none; border: none; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>n8n OpenAI Adapter — Agents</h1>
|
||||
<p>Manage which n8n chat agents are exposed as OpenAI models.</p>
|
||||
|
||||
<label for="key">Admin API key</label>
|
||||
<input type="password" id="key" placeholder="ADMIN_API_KEY" autocomplete="off">
|
||||
<button onclick="loadAgents()">Load agents</button>
|
||||
|
||||
<h2>Add / update agent</h2>
|
||||
<label for="model">Model name</label>
|
||||
<input type="text" id="model" placeholder="e.g. scholar-agent">
|
||||
<label for="webhook">n8n chat webhook URL</label>
|
||||
<input type="text" id="webhook" placeholder="https://n8n.bueso.eu/webhook/<id>/chat">
|
||||
<button onclick="saveAgent()">Save agent</button>
|
||||
|
||||
<div id="msg"></div>
|
||||
|
||||
<h2>Configured agents</h2>
|
||||
<table id="agents"><thead><tr><th>Model</th><th>Webhook</th><th></th></tr></thead><tbody></tbody></table>
|
||||
|
||||
<script>
|
||||
const key = () => document.getElementById('key').value.trim();
|
||||
const msg = (text, ok) => {
|
||||
const el = document.getElementById('msg');
|
||||
el.className = 'msg ' + (ok ? 'ok' : 'err');
|
||||
el.textContent = text;
|
||||
};
|
||||
const auth = () => ({ 'Authorization': 'Bearer ' + key(), 'Content-Type': 'application/json' });
|
||||
|
||||
async function loadAgents() {
|
||||
if (!key()) return msg('Enter the admin API key first', false);
|
||||
try {
|
||||
const r = await fetch('/admin/agents', { headers: auth() });
|
||||
if (!r.ok) return msg('Failed to load agents: HTTP ' + r.status, false);
|
||||
const data = await r.json();
|
||||
const tbody = document.querySelector('#agents tbody');
|
||||
tbody.innerHTML = '';
|
||||
for (const [model, webhook] of Object.entries(data.agents)) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td>' + model + '</td><td>' + webhook + '</td>' +
|
||||
'<td><button class="del" onclick="deleteAgent(\\'' + model + '\\')">Delete</button></td>';
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
msg('Loaded ' + Object.keys(data.agents).length + ' agent(s)', true);
|
||||
} catch (e) { msg('Error: ' + e, false); }
|
||||
}
|
||||
|
||||
async function saveAgent() {
|
||||
const model = document.getElementById('model').value.trim();
|
||||
const webhook = document.getElementById('webhook').value.trim();
|
||||
if (!key()) return msg('Enter the admin API key first', false);
|
||||
if (!model || !webhook) return msg('Model and webhook are required', false);
|
||||
try {
|
||||
const r = await fetch('/admin/agents', {
|
||||
method: 'POST', headers: auth(),
|
||||
body: JSON.stringify({ model, webhook })
|
||||
});
|
||||
if (!r.ok) return msg('Failed to save agent: HTTP ' + r.status, false);
|
||||
document.getElementById('model').value = '';
|
||||
document.getElementById('webhook').value = '';
|
||||
msg('Saved agent "' + model + '"', true);
|
||||
loadAgents();
|
||||
} catch (e) { msg('Error: ' + e, false); }
|
||||
}
|
||||
|
||||
async function deleteAgent(model) {
|
||||
if (!key()) return msg('Enter the admin API key first', false);
|
||||
if (!confirm('Delete agent "' + model + '"?')) return;
|
||||
try {
|
||||
const r = await fetch('/admin/agents/' + encodeURIComponent(model), {
|
||||
method: 'DELETE', headers: auth()
|
||||
});
|
||||
if (!r.ok) return msg('Failed to delete: HTTP ' + r.status, false);
|
||||
msg('Deleted agent "' + model + '"', true);
|
||||
loadAgents();
|
||||
} catch (e) { msg('Error: ' + e, false); }
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -63,6 +63,26 @@ defmodule N8nOpenaiAdapter.RouterTest do
|
||||
assert conn.status == 400
|
||||
end
|
||||
|
||||
test "GET /admin serves the web admin page (admin key)" do
|
||||
conn =
|
||||
conn(:get, "/admin")
|
||||
|> put_req_header("authorization", "Bearer test-admin-key")
|
||||
|> Router.call(Router.init([]))
|
||||
|
||||
assert conn.status == 200
|
||||
assert conn.resp_body =~ "n8n OpenAI Adapter"
|
||||
assert conn.resp_body =~ "admin/agents"
|
||||
end
|
||||
|
||||
test "GET /admin rejects a non-admin key" do
|
||||
conn =
|
||||
conn(:get, "/admin")
|
||||
|> put_req_header("authorization", "Bearer test-key")
|
||||
|> Router.call(Router.init([]))
|
||||
|
||||
assert conn.status == 401
|
||||
end
|
||||
|
||||
# --- Admin API ---
|
||||
|
||||
test "GET /admin/agents lists agents (admin key)" do
|
||||
|
||||
Reference in New Issue
Block a user