Skip to content

Commit 8a15e02

Browse files
authored
feat: Add login with nexus (#34)
* feat: Add login with nexus * fix: Fix dashboard pass
1 parent 7f84a2c commit 8a15e02

3 files changed

Lines changed: 442 additions & 12 deletions

File tree

dappnode/patch-config.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,16 @@ def configure_dashboard_auth(config):
8989
if not str(basic.get("secret") or "").strip():
9090
basic["secret"] = secrets.token_urlsafe(32)
9191

92-
if str(basic.get("password_hash") or "").strip() or str(basic.get("password") or "").strip():
92+
password = read_dashboard_password(username)
93+
has_config_password = bool(str(basic.get("password_hash") or "").strip() or str(basic.get("password") or "").strip())
94+
if password and has_config_password:
9395
return False
9496

95-
password = read_dashboard_password(username) or secrets.token_urlsafe(24)
97+
# If Hermes already has only a password hash but DAppNode has no saved
98+
# plaintext credential, the setup wizard cannot perform its auto-login
99+
# handoff. Generate a new DAppNode-managed password and keep both files in
100+
# sync so users are not stranded at the raw dashboard login screen.
101+
password = password or secrets.token_urlsafe(24)
96102

97103
try:
98104
from plugins.dashboard_auth.basic import hash_password
@@ -102,6 +108,7 @@ def configure_dashboard_auth(config):
102108
except Exception:
103109
# The bundled provider can hash plaintext at load time. This fallback
104110
# keeps the dashboard gated even if the helper import moves upstream.
111+
basic["password_hash"] = ""
105112
basic["password"] = password
106113

107114
write_dashboard_password(username, password)

setup-wizard/index.html

Lines changed: 181 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,48 @@
515515
color: var(--error);
516516
}
517517

518+
.nexus-auth-panel {
519+
background: rgba(255, 215, 0, 0.08);
520+
border: 1px solid rgba(255, 215, 0, 0.25);
521+
border-radius: 10px;
522+
padding: 1rem;
523+
margin-bottom: 1.25rem;
524+
}
525+
526+
.nexus-auth-panel .btn {
527+
width: 100%;
528+
}
529+
530+
.nexus-auth-note {
531+
color: var(--text-muted);
532+
font-size: 0.82rem;
533+
line-height: 1.45;
534+
margin-top: 0.75rem;
535+
}
536+
537+
.nexus-auth-result {
538+
display: none;
539+
margin-top: 0.75rem;
540+
padding: 10px 12px;
541+
border-radius: 8px;
542+
font-size: 0.85rem;
543+
line-height: 1.45;
544+
}
545+
546+
.nexus-auth-result.ok {
547+
display: block;
548+
background: rgba(52, 211, 153, 0.12);
549+
border: 1px solid rgba(52, 211, 153, 0.3);
550+
color: var(--success);
551+
}
552+
553+
.nexus-auth-result.fail {
554+
display: block;
555+
background: rgba(248, 113, 113, 0.12);
556+
border: 1px solid rgba(248, 113, 113, 0.3);
557+
color: var(--error);
558+
}
559+
518560
.config-preview {
519561
background: var(--card);
520562
border: 1px solid var(--border);
@@ -659,6 +701,16 @@ <h2>Choose your AI Provider</h2>
659701
<div class="step" data-step="2">
660702
<h2>Integrations</h2>
661703
<div class="subtitle">Optional — skip any you don't need.</div>
704+
<div class="field">
705+
<label>Dashboard Username</label>
706+
<div class="hint">Used for the Hermes Dashboard login.</div>
707+
<input type="text" id="dashboard-username" value="dappnode" autocomplete="username" placeholder="dappnode">
708+
</div>
709+
<div class="field">
710+
<label>Dashboard Password</label>
711+
<div class="hint">Leave blank to keep the current dashboard password.</div>
712+
<input type="password" id="dashboard-password" autocomplete="new-password" placeholder="Set a dashboard password">
713+
</div>
662714
<div class="field">
663715
<label>Telegram Bot Token <span class="optional-badge">optional</span></label>
664716
<div class="hint">From <strong>@BotFather</strong> on Telegram.</div>
@@ -783,6 +835,22 @@ <h2 style="text-align:center">Configuration Saved!</h2>
783835
// =========================================================================
784836
let _dashboardPollTimer = null;
785837

838+
function dashboardUsernameFromConfig(raw, env) {
839+
const fromEnv = (env.HERMES_DASHBOARD_BASIC_AUTH_USERNAME || "").trim();
840+
if (fromEnv) return fromEnv;
841+
const match = String(raw || "").match(/basic_auth:\s*[\s\S]*?username:\s*["']?([^"'\n]+)/);
842+
return match ? match[1].trim() : "dappnode";
843+
}
844+
845+
function hydrateDashboardAuthFields(raw, env) {
846+
existingEnv = env || {};
847+
existingDashboardUsername = dashboardUsernameFromConfig(raw, existingEnv);
848+
const usernameEl = document.getElementById("dashboard-username");
849+
if (usernameEl && !dashboardUsernameTouched) {
850+
usernameEl.value = existingDashboardUsername;
851+
}
852+
}
853+
786854
async function refreshDashboard() {
787855
try {
788856
const resp = await fetch("/api/health");
@@ -814,6 +882,7 @@ <h2 style="text-align:center">Configuration Saved!</h2>
814882
const data = await resp.json();
815883
const env = data.env || {};
816884
const raw = data.config || "";
885+
hydrateDashboardAuthFields(raw, env);
817886
const provMatch = raw.match(/provider:\s*["']?([^"'\n]+)/);
818887
const modelMatch = raw.match(/default:\s*["']?([^"'\n]+)/);
819888
document.getElementById("st-provider").textContent = provMatch ? provMatch[1].trim() : (env.LLM_MODEL ? "custom" : "—");
@@ -861,6 +930,9 @@ <h2 style="text-align:center">Configuration Saved!</h2>
861930
let selectedProvider = null;
862931
let openRouterModels = [];
863932
let nexusModels = [];
933+
let existingEnv = {};
934+
let existingDashboardUsername = "";
935+
let dashboardUsernameTouched = false;
864936

865937
// =========================================================================
866938
// Render provider cards
@@ -888,6 +960,9 @@ <h2 style="text-align:center">Configuration Saved!</h2>
888960
document.getElementById("telegram-token").addEventListener("input", (e) => {
889961
document.getElementById("telegram-users-field").style.display = e.target.value.trim() ? "block" : "none";
890962
});
963+
document.getElementById("dashboard-username").addEventListener("input", () => {
964+
dashboardUsernameTouched = true;
965+
});
891966
document.getElementById("whatsapp-enabled").addEventListener("change", (e) => {
892967
document.getElementById("whatsapp-users-field").style.display = e.target.checked ? "block" : "none";
893968
document.getElementById("whatsapp-pair-notice").style.display = e.target.checked ? "block" : "none";
@@ -974,10 +1049,15 @@ <h2 style="text-align:center">Configuration Saved!</h2>
9741049
if (p.id === "nexus") {
9751050
container.innerHTML = `
9761051
<h2>Configure DAppNode Nexus</h2>
977-
<div class="subtitle">DAppNode's privacy-focused AI gateway. Your prompts are never logged, stored, or used for training. <a href="https://nexus.dappnode.com" target="_blank" style="color:var(--primary)">Sign up &amp; get your API key &rarr;</a></div>
1052+
<div class="subtitle">DAppNode's privacy-focused AI gateway. Your prompts are never logged, stored, or used for training.</div>
1053+
<div class="nexus-auth-panel">
1054+
<button class="btn btn-primary" id="nexus-login-btn" onclick="startNexusLogin()">Login with Nexus &amp; create API key</button>
1055+
<div class="nexus-auth-note">Creates a Nexus API key for this Hermes Agent account and fills it below. <a href="https://nexus.dappnode.com/api-keys" target="_blank" style="color:var(--primary)">Manage your keys</a>.</div>
1056+
<div class="nexus-auth-result" id="nexus-auth-result"></div>
1057+
</div>
9781058
<div class="field">
9791059
<label>Nexus API Key</label>
980-
<div class="hint">Sign up at <strong>nexus.dappnode.com</strong> and create an API key from your dashboard.</div>
1060+
<div class="hint">Use Nexus login above, or paste a key from <a href="https://nexus.dappnode.com/api-keys" target="_blank" style="color:var(--primary)">nexus.dappnode.com/api-keys</a>.</div>
9811061
<input type="password" id="api-key" placeholder="Paste your Nexus API key here">
9821062
</div>
9831063
<div class="field">
@@ -1145,6 +1225,75 @@ <h2>Configure ${p.name}</h2>
11451225
}
11461226
}
11471227

1228+
function showNexusAuthResult(kind, message) {
1229+
const el = document.getElementById("nexus-auth-result");
1230+
if (!el) return;
1231+
el.textContent = message;
1232+
el.className = "nexus-auth-result " + (kind === "ok" ? "ok" : "fail");
1233+
}
1234+
1235+
function removeNexusAuthParams() {
1236+
const next = new URL(window.location.href);
1237+
for (const key of ["nexus_auth", "nexus_message", "nexus_result"]) {
1238+
next.searchParams.delete(key);
1239+
}
1240+
window.history.replaceState(null, "", next.pathname + next.search + next.hash);
1241+
}
1242+
1243+
function startNexusLogin() {
1244+
const btn = document.getElementById("nexus-login-btn");
1245+
if (btn) {
1246+
btn.disabled = true;
1247+
btn.textContent = "Opening Nexus login...";
1248+
}
1249+
const current = new URL(window.location.href);
1250+
for (const key of ["nexus_auth", "nexus_message", "nexus_result"]) {
1251+
current.searchParams.delete(key);
1252+
}
1253+
const returnTo = current.pathname + current.search + current.hash;
1254+
window.location.href = "/nexus/auth/start?returnTo=" + encodeURIComponent(returnTo || "/");
1255+
}
1256+
1257+
async function handleNexusAuthRedirect() {
1258+
const params = new URLSearchParams(window.location.search);
1259+
const status = params.get("nexus_auth");
1260+
if (!status) return;
1261+
1262+
switchTab("setup");
1263+
selectProviderCard("nexus");
1264+
goTo(1);
1265+
1266+
try {
1267+
if (status === "error") {
1268+
showNexusAuthResult("fail", params.get("nexus_message") || "Nexus login failed. Please try again.");
1269+
return;
1270+
}
1271+
1272+
const resultId = params.get("nexus_result");
1273+
if (!resultId) {
1274+
showNexusAuthResult("fail", "Nexus login finished without an API key result. Please try again.");
1275+
return;
1276+
}
1277+
1278+
showNexusAuthResult("ok", "Creating Nexus API key...");
1279+
const resp = await fetch("/api/nexus/auth/result", {
1280+
method: "POST",
1281+
headers: { "Content-Type": "application/json" },
1282+
body: JSON.stringify({ id: resultId }),
1283+
});
1284+
const data = await resp.json().catch(() => ({}));
1285+
if (!resp.ok) throw new Error(data.error || "Could not retrieve the generated Nexus API key");
1286+
1287+
const input = document.getElementById("api-key");
1288+
if (input) input.value = data.apiKey || "";
1289+
showNexusAuthResult("ok", "Nexus API key created. Choose a model and continue.");
1290+
} catch (err) {
1291+
showNexusAuthResult("fail", err.message || "Nexus login failed. Please try again.");
1292+
} finally {
1293+
removeNexusAuthParams();
1294+
}
1295+
}
1296+
11481297
async function probeOllama() {
11491298
const btn = document.getElementById("probe-btn");
11501299
const icon = document.getElementById("probe-icon");
@@ -1188,6 +1337,24 @@ <h2>Configure ${p.name}</h2>
11881337
return base.endsWith("/v1") ? base : base + "/v1";
11891338
}
11901339

1340+
function buildDashboardAuthEnv() {
1341+
const env = {};
1342+
const username = (document.getElementById("dashboard-username")?.value || "").trim();
1343+
const password = (document.getElementById("dashboard-password")?.value || "").trim();
1344+
if (username) env.HERMES_DASHBOARD_BASIC_AUTH_USERNAME = username;
1345+
if (password) env.HERMES_DASHBOARD_BASIC_AUTH_PASSWORD = password;
1346+
return env;
1347+
}
1348+
1349+
function dashboardAuthChanged() {
1350+
const dashboardEnv = buildDashboardAuthEnv();
1351+
const username = dashboardEnv.HERMES_DASHBOARD_BASIC_AUTH_USERNAME || "";
1352+
return Boolean(
1353+
dashboardEnv.HERMES_DASHBOARD_BASIC_AUTH_PASSWORD
1354+
|| (username && username !== existingDashboardUsername)
1355+
);
1356+
}
1357+
11911358
function buildEnv() {
11921359
const env = {};
11931360
const p = selectedProvider;
@@ -1231,6 +1398,7 @@ <h2>Configure ${p.name}</h2>
12311398
const waUsers = (document.getElementById("whatsapp-users").value || "").trim();
12321399
if (waUsers) env.WHATSAPP_ALLOWED_USERS = waUsers;
12331400
}
1401+
Object.assign(env, buildDashboardAuthEnv());
12341402
return env;
12351403
}
12361404

@@ -1309,7 +1477,9 @@ <h2>Configure ${p.name}</h2>
13091477
const yaml = buildConfigYaml();
13101478
const envLines = [];
13111479
for (const [k, v] of Object.entries(env)) {
1312-
if (k.includes("KEY") || k.includes("TOKEN") || k.includes("SECRET")) {
1480+
if (k.includes("PASSWORD")) {
1481+
envLines.push(`${k}=${"*".repeat(Math.max(8, v.length))}`);
1482+
} else if (k.includes("KEY") || k.includes("TOKEN") || k.includes("SECRET")) {
13131483
envLines.push(`${k}=${v.slice(0, 8)}${"*".repeat(Math.max(0, v.length - 8))}`);
13141484
} else { envLines.push(`${k}=${v}`); }
13151485
}
@@ -1326,7 +1496,12 @@ <h2>Configure ${p.name}</h2>
13261496
const btn = document.getElementById("btn-save");
13271497
btn.disabled = true; btn.textContent = "Saving...";
13281498
const env = buildEnv();
1329-
const needsRestart = !!(env.TELEGRAM_BOT_TOKEN || env.WHATSAPP_ENABLED);
1499+
if (env.HERMES_DASHBOARD_BASIC_AUTH_PASSWORD && !env.HERMES_DASHBOARD_BASIC_AUTH_USERNAME) {
1500+
alert("Dashboard username is required when setting a dashboard password.");
1501+
btn.disabled = false; btn.textContent = "Save Configuration";
1502+
return;
1503+
}
1504+
const needsRestart = !!(env.TELEGRAM_BOT_TOKEN || env.WHATSAPP_ENABLED || dashboardAuthChanged());
13301505
try {
13311506
const resp = await fetch("/api/config", {
13321507
method: "POST",
@@ -1348,6 +1523,8 @@ <h2>Configure ${p.name}</h2>
13481523
btn.disabled = false; btn.textContent = "Save Configuration";
13491524
}
13501525
}
1526+
1527+
handleNexusAuthRedirect();
13511528
</script>
13521529
</body>
13531530

0 commit comments

Comments
 (0)