feat: federation key management UI in Settings > Multi-Device

This commit is contained in:
Brian Krabach
2026-04-01 16:50:01 -07:00
parent 77e2b8476b
commit 63e7ef2a1c
4 changed files with 104 additions and 5 deletions
+29 -5
View File
@@ -1409,12 +1409,13 @@ async function patchServerSetting(key, value) {
} }
/** /**
* Build a single remote instance row element with URL input, name input, and remove button. * Build a single remote instance row element with URL input, name input, key input, and remove button.
* @param {string} url - remote instance URL * @param {string} url - remote instance URL
* @param {string} name - remote instance display name * @param {string} name - remote instance display name
* @param {string} key - federation key for the remote instance
* @returns {HTMLDivElement} * @returns {HTMLDivElement}
*/ */
function _buildRemoteInstanceRow(url, name) { function _buildRemoteInstanceRow(url, name, key) {
var row = document.createElement('div'); var row = document.createElement('div');
row.className = 'settings-remote-row'; row.className = 'settings-remote-row';
var urlInput = document.createElement('input'); var urlInput = document.createElement('input');
@@ -1429,12 +1430,19 @@ function _buildRemoteInstanceRow(url, name) {
nameInput.placeholder = 'Device name'; nameInput.placeholder = 'Device name';
nameInput.value = name || ''; nameInput.value = name || '';
nameInput.setAttribute('aria-label', 'Remote instance display name'); nameInput.setAttribute('aria-label', 'Remote instance display name');
var keyInput = document.createElement('input');
keyInput.type = 'password';
keyInput.className = 'settings-remote-key';
keyInput.placeholder = 'Federation key';
keyInput.value = key || '';
keyInput.setAttribute('aria-label', 'Federation key for remote instance');
var removeBtn = document.createElement('button'); var removeBtn = document.createElement('button');
removeBtn.className = 'settings-remote-remove'; removeBtn.className = 'settings-remote-remove';
removeBtn.textContent = '\u00d7'; removeBtn.textContent = '\u00d7';
removeBtn.setAttribute('aria-label', 'Remove remote instance'); removeBtn.setAttribute('aria-label', 'Remove remote instance');
row.appendChild(urlInput); row.appendChild(urlInput);
row.appendChild(nameInput); row.appendChild(nameInput);
row.appendChild(keyInput);
row.appendChild(removeBtn); row.appendChild(removeBtn);
return row; return row;
} }
@@ -1449,10 +1457,12 @@ function _saveRemoteInstances() {
container.querySelectorAll('.settings-remote-row').forEach(function(row) { container.querySelectorAll('.settings-remote-row').forEach(function(row) {
var urlEl = row.querySelector('.settings-remote-url'); var urlEl = row.querySelector('.settings-remote-url');
var nameEl = row.querySelector('.settings-remote-name'); var nameEl = row.querySelector('.settings-remote-name');
var keyEl = row.querySelector('.settings-remote-key');
var url = (urlEl && urlEl.value) ? urlEl.value.trim() : ''; var url = (urlEl && urlEl.value) ? urlEl.value.trim() : '';
var name = (nameEl && nameEl.value) ? nameEl.value.trim() : ''; var name = (nameEl && nameEl.value) ? nameEl.value.trim() : '';
var key = (keyEl && keyEl.value) ? keyEl.value.trim() : '';
if (url) { if (url) {
instances.push({ url: url, name: name }); instances.push({ url: url, name: name, key: key });
} }
}); });
patchServerSetting('remote_instances', instances); patchServerSetting('remote_instances', instances);
@@ -1799,7 +1809,7 @@ function openSettings() {
remoteInstancesEl.innerHTML = ''; remoteInstancesEl.innerHTML = '';
var remotes = (ss && ss.remote_instances) || []; var remotes = (ss && ss.remote_instances) || [];
remotes.forEach(function(r) { remotes.forEach(function(r) {
remoteInstancesEl.appendChild(_buildRemoteInstanceRow(r.url || '', r.name || '')); remoteInstancesEl.appendChild(_buildRemoteInstanceRow(r.url || '', r.name || '', r.key || ''));
}); });
} }
@@ -2336,11 +2346,25 @@ function bindStaticEventListeners() {
}, 500); }, 500);
}); });
// Multi-Device tab — federation generate key button
on($('federation-generate-btn'), 'click', function() {
api('POST', '/api/federation/generate-key').then(function(data) {
var displayEl = $('federation-key-display');
if (displayEl && data && data.key) {
displayEl.textContent = data.key;
displayEl.classList.add('settings-key-display--visible');
}
showToast('Federation key generated');
}).catch(function() {
showToast('Failed to generate federation key');
});
});
// Multi-Device tab — add remote instance button // Multi-Device tab — add remote instance button
on($('add-remote-instance-btn'), 'click', function() { on($('add-remote-instance-btn'), 'click', function() {
var container = $('setting-remote-instances'); var container = $('setting-remote-instances');
if (container) { if (container) {
container.appendChild(_buildRemoteInstanceRow('', '')); container.appendChild(_buildRemoteInstanceRow('', '', ''));
} }
}); });
+8
View File
@@ -242,6 +242,14 @@
<option value="local">Local Only</option> <option value="local">Local Only</option>
</select> </select>
</div> </div>
<div class="settings-field settings-field--column">
<label class="settings-label">Federation Key</label>
<div class="settings-federation-key">
<span id="federation-key-display" class="settings-key-display">&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;</span>
<button id="federation-generate-btn" class="settings-action-btn">Generate Key</button>
</div>
<span class="settings-helper">Share this key with remote instances to allow federation access</span>
</div>
<div class="settings-field settings-field--column"> <div class="settings-field settings-field--column">
<label class="settings-label">Remote Instances</label> <label class="settings-label">Remote Instances</label>
<div id="setting-remote-instances" class="settings-remote-list"></div> <div id="setting-remote-instances" class="settings-remote-list"></div>
+33
View File
@@ -1746,3 +1746,36 @@ body {
#setting-device-name { #setting-device-name {
max-width: 200px; max-width: 200px;
} }
/* Federation key display */
.settings-federation-key {
display: flex;
align-items: center;
gap: 8px;
}
.settings-key-display {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-muted);
user-select: all;
}
.settings-key-display--visible {
color: var(--accent);
background: var(--accent-dim);
padding: 4px 8px;
border-radius: 4px;
}
/* Remote instance federation key input */
.settings-remote-key {
flex: 1;
min-width: 100px;
font-size: 12px;
padding: 4px 6px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
}
+34
View File
@@ -1337,3 +1337,37 @@ def test_html_display_panel_no_view_scope() -> None:
assert el is None, ( assert el is None, (
"#setting-view-scope must NOT be in the display panel (moved to Multi-Device tab)" "#setting-view-scope must NOT be in the display panel (moved to Multi-Device tab)"
) )
# ============================================================
# Federation Key Management UI (task-6-federation-key-ui)
# ============================================================
def test_html_settings_federation_key_section() -> None:
"""Multi-Device tab must contain federation-key-display and federation-generate-btn."""
soup = _SOUP
devices_panel = soup.find("div", attrs={"data-tab": "devices"})
assert devices_panel is not None, "Missing devices panel (data-tab='devices')"
key_display = devices_panel.find(id="federation-key-display")
assert key_display is not None, (
"Missing #federation-key-display inside devices panel"
)
generate_btn = devices_panel.find(id="federation-generate-btn")
assert generate_btn is not None, (
"Missing #federation-generate-btn inside devices panel"
)
assert generate_btn.name == "button", (
f"#federation-generate-btn must be a <button>, got: {generate_btn.name}"
)
def test_html_settings_remote_instance_key_input() -> None:
"""Multi-Device tab must contain #setting-remote-instances container."""
soup = _SOUP
devices_panel = soup.find("div", attrs={"data-tab": "devices"})
assert devices_panel is not None, "Missing devices panel (data-tab='devices')"
el = devices_panel.find(id="setting-remote-instances")
assert el is not None, (
"Missing #setting-remote-instances inside devices panel"
)