feat: federation key management UI in Settings > Multi-Device
This commit is contained in:
+29
-5
@@ -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('', '', ''));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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">••••••••</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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user