Skip to content

Commit 30fd688

Browse files
Added admin feature to allow users to change their Spark seed phrase in the UI
1 parent 462ae99 commit 30fd688

10 files changed

Lines changed: 394 additions & 3 deletions

File tree

flake.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
outputs = { self, nixpkgs, raspberry-pi-nix, lnbits, spark-sidecar, ... }:
2323
let
24-
version = "0.1.47"; # Bump before each release tag to match the next tag name
24+
version = "0.1.48"; # Bump before each release tag to match the next tag name
2525
system = "aarch64-linux";
2626
in
2727
{

nixos/admin-app/app.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import sys
88
import tempfile
99
import time
10+
import grp
1011
try:
1112
import crypt
1213
except ModuleNotFoundError:
@@ -21,6 +22,7 @@
2122
from pathlib import Path
2223
from typing import Any
2324

25+
from mnemonic import Mnemonic
2426
from flask import (
2527
Flask, render_template, request, redirect,
2628
url_for, flash, session, jsonify, send_file
@@ -265,6 +267,36 @@ def _read_spark_mnemonic() -> str | None:
265267
return None
266268

267269

270+
def _normalize_mnemonic(value: str) -> str:
271+
return " ".join(value.strip().lower().split())
272+
273+
274+
def _write_spark_mnemonic(mnemonic: str):
275+
SPARK_MNEMONIC_FILE.parent.mkdir(parents=True, exist_ok=True, mode=0o750)
276+
fd, tmp_path_str = tempfile.mkstemp(
277+
prefix=".mnemonic.",
278+
dir=str(SPARK_MNEMONIC_FILE.parent),
279+
text=True,
280+
)
281+
tmp_path = Path(tmp_path_str)
282+
try:
283+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
284+
handle.write(mnemonic + "\n")
285+
os.chmod(tmp_path, 0o640)
286+
try:
287+
spark_gid = grp.getgrnam("spark-sidecar").gr_gid
288+
os.chown(tmp_path, 0, spark_gid)
289+
except KeyError:
290+
pass
291+
tmp_path.replace(SPARK_MNEMONIC_FILE)
292+
finally:
293+
if tmp_path.exists():
294+
try:
295+
tmp_path.unlink()
296+
except OSError:
297+
pass
298+
299+
268300
def _runtime_env_content(tunnel: dict[str, Any]) -> str:
269301
return "\n".join(
270302
[
@@ -768,6 +800,53 @@ def api_stop_service(service):
768800
return _service_action(service, "stop", "stopping")
769801

770802

803+
@app.route("/box/api/spark/seed", methods=["POST"])
804+
@login_required
805+
def api_update_spark_seed():
806+
payload = request.get_json(silent=True) or {}
807+
new_mnemonic = _normalize_mnemonic(str(payload.get("mnemonic", "")))
808+
809+
if not new_mnemonic:
810+
return _json_error("Enter a seed phrase.", 400)
811+
812+
words = new_mnemonic.split()
813+
if len(words) != 12:
814+
return _json_error("Enter exactly 12 words.", 400)
815+
816+
if not Mnemonic("english").check(new_mnemonic):
817+
return _json_error("Enter a valid 12-word BIP39 seed phrase.", 400)
818+
819+
current_mnemonic = _normalize_mnemonic(_read_spark_mnemonic() or "")
820+
if current_mnemonic and new_mnemonic == current_mnemonic:
821+
return _json_error("That seed phrase is already in use.", 400)
822+
823+
if DEV_MODE:
824+
_write_spark_mnemonic(new_mnemonic)
825+
return _json_response(
826+
status="ok",
827+
message="Spark seed phrase updated successfully. Spark is restarting now.",
828+
data={"service": "spark-sidecar", "action": "restart"},
829+
)
830+
831+
try:
832+
_write_spark_mnemonic(new_mnemonic)
833+
subprocess.run(
834+
["systemctl", "restart", "spark-sidecar.service"],
835+
check=True,
836+
capture_output=True,
837+
timeout=30,
838+
)
839+
return _json_response(
840+
status="ok",
841+
message="Spark seed phrase updated successfully. Spark is restarting now.",
842+
data={"service": "spark-sidecar", "action": "restart"},
843+
)
844+
except subprocess.CalledProcessError as e:
845+
return _json_error(e.stderr.decode() or "Failed to restart Spark.", 500)
846+
except Exception as e:
847+
return _json_error(str(e), 500)
848+
849+
771850
def _service_action(service, action, verb):
772851
if service not in ALLOWED_SERVICES:
773852
return _json_error("Invalid service", 400)

nixos/admin-app/static/css/admin.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,10 @@ video {
577577
max-width: 28rem
578578
}
579579

580+
.max-w-lg {
581+
max-width: 32rem
582+
}
583+
580584
.max-w-sm {
581585
max-width: 24rem
582586
}

nixos/admin-app/static/js/dashboard-core.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@
5252
D.state.pendingActionButtonId = null;
5353
const modal = D.el('confirm-modal');
5454
if (modal) modal.classList.add('hidden');
55+
const btn = D.el('confirm-btn');
56+
if (btn) {
57+
btn.textContent = 'Confirm';
58+
btn.onclick = null;
59+
}
5560
};
5661

5762
D.setActionBusy = function (action, sourceButtonId) {
@@ -109,6 +114,7 @@
109114
if (modal) modal.classList.remove('hidden');
110115
const btn = D.el('confirm-btn');
111116
if (!btn) return;
117+
btn.textContent = 'Confirm';
112118
btn.onclick = function () {
113119
D.executeAction(action, D.state.pendingActionButtonId);
114120
};
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
(function () {
2+
const D = window.LNbitsBoxDashboard;
3+
if (!D) return;
4+
5+
const state = {
6+
pendingMnemonic: '',
7+
};
8+
9+
function el(id) {
10+
return document.getElementById(id);
11+
}
12+
13+
function openModal(id) {
14+
const modal = el(id);
15+
if (modal) modal.classList.remove('hidden');
16+
}
17+
18+
function closeModal(id) {
19+
const modal = el(id);
20+
if (modal) modal.classList.add('hidden');
21+
}
22+
23+
function setError(message) {
24+
const errorEl = el('spark-seed-form-error');
25+
if (!errorEl) return;
26+
if (message) {
27+
errorEl.textContent = message;
28+
errorEl.classList.remove('hidden');
29+
} else {
30+
errorEl.textContent = '';
31+
errorEl.classList.add('hidden');
32+
}
33+
}
34+
35+
function normalizeMnemonic(value) {
36+
return (value || '').trim().toLowerCase().split(/\s+/).filter(Boolean).join(' ');
37+
}
38+
39+
function currentMnemonic() {
40+
const seedValue = el('spark-seed-value');
41+
return normalizeMnemonic(seedValue ? seedValue.dataset.seedPhrase || '' : '');
42+
}
43+
44+
function validateMnemonic(value) {
45+
const normalized = normalizeMnemonic(value);
46+
if (!normalized) return 'Enter a seed phrase.';
47+
const words = normalized.split(' ');
48+
if (words.length !== 12) return 'Enter exactly 12 words.';
49+
if (normalized === currentMnemonic()) return 'This is already the current seed phrase.';
50+
return '';
51+
}
52+
53+
function resetFlow() {
54+
state.pendingMnemonic = '';
55+
const input = el('spark-seed-new-input');
56+
if (input) input.value = '';
57+
const checkbox = el('spark-seed-backed-up-checkbox');
58+
if (checkbox) checkbox.checked = false;
59+
const confirmBtn = el('spark-seed-backup-confirm-btn');
60+
if (confirmBtn) confirmBtn.disabled = true;
61+
setError('');
62+
closeModal('spark-seed-change-modal');
63+
closeModal('spark-seed-backup-modal');
64+
D.closeModal();
65+
}
66+
67+
async function submitMnemonic() {
68+
const actionBtn = el('spark-seed-change-continue-btn');
69+
const originalHtml = actionBtn ? actionBtn.innerHTML : '';
70+
if (actionBtn) {
71+
actionBtn.disabled = true;
72+
actionBtn.innerHTML = '<span class="inline-flex items-center gap-2"><span class="w-3.5 h-3.5 border-2 border-white/70 border-t-transparent rounded-full animate-spin"></span><span>Updating...</span></span>';
73+
}
74+
75+
try {
76+
const resp = await fetch('/box/api/spark/seed', {
77+
method: 'POST',
78+
headers: { 'Content-Type': 'application/json' },
79+
body: JSON.stringify({ mnemonic: state.pendingMnemonic }),
80+
});
81+
const data = await resp.json();
82+
if (!resp.ok || data.status !== 'ok') {
83+
closeModal('spark-seed-backup-modal');
84+
openModal('spark-seed-change-modal');
85+
setError(data.message || 'Failed to update the seed phrase.');
86+
return;
87+
}
88+
89+
const seedValue = el('spark-seed-value');
90+
if (seedValue) {
91+
seedValue.dataset.seedPhrase = state.pendingMnemonic;
92+
seedValue.dataset.masked = 'true';
93+
seedValue.textContent = '••••• ••••• ••••• ••••• ••••• ••••• ••••• ••••• ••••• ••••• ••••• •••••';
94+
}
95+
const toggleBtn = el('spark-seed-toggle-btn');
96+
if (toggleBtn) toggleBtn.textContent = 'Show Seed Phrase';
97+
98+
resetFlow();
99+
D.showNotice(data.message || 'Spark seed phrase updated. Spark is restarting automatically.', 'Success');
100+
if (typeof D.fetchStats === 'function') {
101+
setTimeout(D.fetchStats, 1200);
102+
}
103+
} catch (error) {
104+
closeModal('spark-seed-backup-modal');
105+
openModal('spark-seed-change-modal');
106+
setError('Request failed.');
107+
} finally {
108+
if (actionBtn) {
109+
actionBtn.disabled = false;
110+
actionBtn.innerHTML = originalHtml;
111+
}
112+
}
113+
}
114+
115+
function openFinalConfirm() {
116+
closeModal('spark-seed-backup-modal');
117+
D.state.pendingAction = null;
118+
D.state.pendingActionButtonId = null;
119+
D.setText('confirm-title', 'Final confirmation');
120+
D.setText('confirm-message', 'Are you absolutely sure you want to replace the Spark wallet seed phrase now? This cannot be undone from LNbitsBox.');
121+
const confirmBtn = el('confirm-btn');
122+
if (confirmBtn) {
123+
confirmBtn.textContent = 'Replace Seed Phrase';
124+
confirmBtn.onclick = function () {
125+
D.closeModal();
126+
submitMnemonic();
127+
};
128+
}
129+
openModal('confirm-modal');
130+
}
131+
132+
const openBtn = el('spark-seed-change-btn');
133+
const closeBtn = el('spark-seed-change-close-btn');
134+
const cancelBtn = el('spark-seed-change-cancel-btn');
135+
const continueBtn = el('spark-seed-change-continue-btn');
136+
const backupCancelBtn = el('spark-seed-backup-cancel-btn');
137+
const backupConfirmBtn = el('spark-seed-backup-confirm-btn');
138+
const backupCheckbox = el('spark-seed-backed-up-checkbox');
139+
140+
if (openBtn) {
141+
openBtn.addEventListener('click', function () {
142+
setError('');
143+
openModal('spark-seed-change-modal');
144+
const input = el('spark-seed-new-input');
145+
if (input) input.focus();
146+
});
147+
}
148+
149+
[closeBtn, cancelBtn].filter(Boolean).forEach(function (button) {
150+
button.addEventListener('click', function () {
151+
resetFlow();
152+
});
153+
});
154+
155+
if (continueBtn) {
156+
continueBtn.addEventListener('click', function () {
157+
const input = el('spark-seed-new-input');
158+
const normalized = normalizeMnemonic(input ? input.value : '');
159+
const error = validateMnemonic(normalized);
160+
if (error) {
161+
setError(error);
162+
return;
163+
}
164+
state.pendingMnemonic = normalized;
165+
setError('');
166+
closeModal('spark-seed-change-modal');
167+
openModal('spark-seed-backup-modal');
168+
});
169+
}
170+
171+
if (backupCheckbox && backupConfirmBtn) {
172+
backupCheckbox.addEventListener('change', function () {
173+
backupConfirmBtn.disabled = !backupCheckbox.checked;
174+
});
175+
}
176+
177+
if (backupCancelBtn) {
178+
backupCancelBtn.addEventListener('click', function () {
179+
closeModal('spark-seed-backup-modal');
180+
openModal('spark-seed-change-modal');
181+
});
182+
}
183+
184+
if (backupConfirmBtn) {
185+
backupConfirmBtn.addEventListener('click', function () {
186+
if (backupConfirmBtn.disabled) return;
187+
openFinalConfirm();
188+
});
189+
}
190+
191+
document.addEventListener('keydown', function (event) {
192+
if (event.key !== 'Escape') return;
193+
if (!el('spark-seed-change-modal')?.classList.contains('hidden')) {
194+
resetFlow();
195+
return;
196+
}
197+
if (!el('spark-seed-backup-modal')?.classList.contains('hidden')) {
198+
closeModal('spark-seed-backup-modal');
199+
openModal('spark-seed-change-modal');
200+
}
201+
});
202+
})();

nixos/admin-app/templates/advanced.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<script src="{{ url_for('static', filename='js/dashboard-services.js') }}"></script>
1717
<script src="{{ url_for('static', filename='js/dashboard-tunnel.js') }}"></script>
1818
<script src="{{ url_for('static', filename='js/dashboard-app.js') }}"></script>
19+
<script src="{{ url_for('static', filename='js/dashboard-spark.js') }}"></script>
1920
<script>
2021
const sparkSeedValue = document.getElementById('spark-seed-value');
2122
const sparkSeedToggleBtn = document.getElementById('spark-seed-toggle-btn');

nixos/admin-app/templates/dashboard.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@
443443

444444
{# ── Confirm Modal ── #}
445445
<div id="confirm-modal" class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 hidden">
446-
<div class="bg-ln-card border border-ln-border rounded-2xl p-6 max-w-sm w-full glow-pink">
446+
<div class="bg-ln-card border border-ln-border rounded-2xl p-6 max-w-md w-full glow-pink">
447447
<p class="text-lg font-display font-semibold mb-2" id="confirm-title">Are you sure?</p>
448448
<p class="text-ln-muted text-sm font-mono mb-6" id="confirm-message"></p>
449449
<div class="flex gap-3">

0 commit comments

Comments
 (0)