Add session manager and verification list to the server config editor

Adds two interactive admin tools to the matrix-server-config node so
device verification and session management can be done from the editor
without building a flow. Both open as modal dialogs and are backed by
new flows.write-protected admin endpoints.

Pending verifications
- New "Pending verification requests" button opens a modal listing
  incoming and in-progress verification requests, refreshed every 5
  seconds.
- Each entry shows the type (device or room), who it is from, a live
  age, and a live expiry countdown; the list is capped at the newest 20
  with a hidden-count note.
- Clicking an entry drives the SAS flow (accept, start, show emoji,
  confirm/cancel) within the modal.
- New /matrix-chat/verification endpoint with list, advance, confirm,
  mismatch, and cancel actions.
- trackVerificationRequest now records a seen-at timestamp and keeps
  finished requests for two minutes so their outcome can still be
  reported to the editor.

Sessions
- New "Manage sessions" button opens a modal listing the account's
  sessions, modelled on Element's session manager: the current session
  with a verified / not-verified status box, then other sessions, each
  with a green or red shield, last activity, and IP address.
- Clicking a session shows its details (session ID, last activity, IP),
  a Rename action, and a password-confirmed Remove. Unverified sessions
  offer a Verify action that hands off into the verification modal.
- New /matrix-chat/sessions endpoint with list, rename, remove, and
  verify actions.
This commit is contained in:
2026-05-22 15:29:43 -06:00
parent ebcb1eab81
commit 67920840e1
2 changed files with 801 additions and 2 deletions
+551
View File
@@ -204,6 +204,539 @@
$("#matrix-sb-overlay").data("sbStatusFn")(); $("#matrix-sb-overlay").data("sbStatusFn")();
}); });
// --- Verification list (modal) ---
// Built once and reused; the node id is stored on the overlay.
if (!document.getElementById("matrix-vl-overlay")) {
$('<style>'
+ '.matrix-vl-overlay{position:fixed;inset:0;z-index:3000;display:none;background:rgba(0,0,0,.45);}'
+ '.matrix-vl-modal{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);'
+ 'width:560px;max-width:92vw;max-height:88vh;display:flex;flex-direction:column;'
+ 'border-radius:6px;overflow:hidden;box-shadow:0 12px 44px rgba(0,0,0,.45);'
+ 'background:var(--red-ui-primary-background,#fff);color:var(--red-ui-primary-text-color,#2a2a2a);}'
+ '.matrix-vl-head{display:flex;justify-content:space-between;align-items:center;'
+ 'padding:12px 16px;font-size:15px;font-weight:bold;'
+ 'background:var(--red-ui-secondary-background,#f3f3f3);'
+ 'border-bottom:1px solid var(--red-ui-secondary-border-color,#ddd);}'
+ '.matrix-vl-x{cursor:pointer;font-size:20px;line-height:1;opacity:.55;}'
+ '.matrix-vl-x:hover{opacity:1;}'
+ '.matrix-vl-body{padding:14px 16px;overflow:auto;}'
+ '.matrix-vl-note{font-size:12px;color:var(--red-ui-secondary-text-color,#888);margin-bottom:10px;}'
+ '.matrix-vl-item{display:flex;justify-content:space-between;gap:12px;padding:10px 12px;'
+ 'margin-bottom:8px;border-radius:5px;cursor:pointer;'
+ 'border:1px solid var(--red-ui-secondary-border-color,#ddd);'
+ 'background:var(--red-ui-secondary-background,#f7f7f7);}'
+ '.matrix-vl-item:hover{border-color:var(--red-ui-node-border,#999);}'
+ '.matrix-vl-item-l{flex:1;min-width:0;}'
+ '.matrix-vl-item-title{font-weight:bold;}'
+ '.matrix-vl-item-sub{font-size:12px;color:var(--red-ui-secondary-text-color,#888);'
+ 'margin-top:2px;word-break:break-word;}'
+ '.matrix-vl-item-r{text-align:right;font-size:12px;white-space:nowrap;}'
+ '.matrix-vl-exp{color:#d18a1b;}'
+ '.matrix-vl-empty,.matrix-vl-more{font-size:13px;'
+ 'color:var(--red-ui-secondary-text-color,#888);padding:6px 2px;}'
+ '.matrix-vl-d-state{display:flex;gap:10px;align-items:flex-start;font-size:14px;line-height:1.5;}'
+ '.matrix-vl-d-state .fa{font-size:18px;}'
+ '.matrix-vl-sas{display:flex;flex-wrap:wrap;gap:10px;margin:14px 0;}'
+ '.matrix-vl-emoji{width:88px;text-align:center;padding:8px 4px;border-radius:5px;'
+ 'background:var(--red-ui-secondary-background,#f3f3f3);'
+ 'border:1px solid var(--red-ui-secondary-border-color,#ddd);}'
+ '.matrix-vl-emoji .e{font-size:30px;line-height:1.3;}'
+ '.matrix-vl-emoji .n{font-size:11px;color:var(--red-ui-secondary-text-color,#888);text-transform:capitalize;}'
+ '.matrix-vl-result{margin-top:12px;padding:10px 12px;border-radius:4px;font-size:13px;}'
+ '.matrix-vl-foot{display:flex;align-items:center;gap:8px;padding:10px 16px;'
+ 'background:var(--red-ui-secondary-background,#f3f3f3);'
+ 'border-top:1px solid var(--red-ui-secondary-border-color,#ddd);}'
+ '</style>').appendTo("head");
$('<div id="matrix-vl-overlay" class="matrix-vl-overlay"><div class="matrix-vl-modal">'
+ '<div class="matrix-vl-head"><span><i class="fa fa-check-circle"></i> Device Verification</span>'
+ '<span class="matrix-vl-x" id="matrix-vl-x" title="Close">&times;</span></div>'
+ '<div class="matrix-vl-body">'
+ '<div id="matrix-vl-listview">'
+ '<div class="matrix-vl-note">Pending verification requests &mdash; this list refreshes every 5 seconds. Click a request to verify it.</div>'
+ '<div id="matrix-vl-items"></div>'
+ '<div id="matrix-vl-empty" class="matrix-vl-empty" style="display:none;">No pending verification requests.</div>'
+ '<div id="matrix-vl-more" class="matrix-vl-more" style="display:none;"></div>'
+ '</div>'
+ '<div id="matrix-vl-detailview" style="display:none;">'
+ '<div id="matrix-vl-d-head" class="matrix-vl-item-sub" style="margin-bottom:10px;"></div>'
+ '<div id="matrix-vl-d-state" class="matrix-vl-d-state"></div>'
+ '<div id="matrix-vl-d-sas" class="matrix-vl-sas" style="display:none;"></div>'
+ '<div id="matrix-vl-d-actions" style="display:none;text-align:right;">'
+ '<button type="button" class="red-ui-button" id="matrix-vl-d-mismatch">They don&#39;t match</button> '
+ '<button type="button" class="red-ui-button" id="matrix-vl-d-confirm">They match</button>'
+ '</div>'
+ '<div id="matrix-vl-d-result" class="matrix-vl-result" style="display:none;"></div>'
+ '</div>'
+ '</div>'
+ '<div class="matrix-vl-foot">'
+ '<button type="button" class="red-ui-button" id="matrix-vl-back" style="display:none;">&larr; Back to list</button>'
+ '<span style="flex:1;"></span>'
+ '<button type="button" class="red-ui-button" id="matrix-vl-cancel" style="display:none;">Cancel verification</button>'
+ '<button type="button" class="red-ui-button" id="matrix-vl-close">Close</button>'
+ '</div></div></div>').appendTo(document.body);
var vlListTimer = null, vlTickTimer = null, vlDetailTimer = null;
var vlEsc = function(s) { return $("<div>").text(s == null ? "" : String(s)).html(); };
var vlId = function() { return $("#matrix-vl-overlay").data("matrixNodeId"); };
var vlCurId = function() { return $("#matrix-vl-overlay").data("vlCurrentId"); };
var vlCall = function(body) {
return $.ajax({
url: "matrix-chat/verification", type: "POST",
contentType: "application/json", data: JSON.stringify(body),
});
};
var vlClearTimers = function() {
if(vlListTimer) { clearInterval(vlListTimer); vlListTimer = null; }
if(vlTickTimer) { clearInterval(vlTickTimer); vlTickTimer = null; }
if(vlDetailTimer) { clearInterval(vlDetailTimer); vlDetailTimer = null; }
};
var vlDur = function(ms) {
var s = Math.max(0, Math.round(ms / 1000));
if(s < 60) { return s + "s"; }
var m = Math.floor(s / 60), r = s % 60;
return m + "m " + (r < 10 ? "0" : "") + r + "s";
};
var vlState = function(icon, color, html) {
$("#matrix-vl-d-state").html('<i class="fa ' + icon + '" style="color:' + color + ';"></i><span>' + html + '</span>');
};
var vlTick = function() {
var now = Date.now();
$("#matrix-vl-items .matrix-vl-item").each(function() {
var $it = $(this);
var seen = parseInt($it.attr("data-seen"), 10);
var expires = $it.attr("data-expires");
$it.find(".matrix-vl-age").text(isNaN(seen) ? "" : "age " + vlDur(now - seen));
if(expires) {
var left = parseInt(expires, 10) - now;
$it.find(".matrix-vl-exp").text(left > 0 ? "expires in " + vlDur(left) : "expired");
}
});
};
var vlRenderList = function(data) {
if(data.result !== "ok") {
$("#matrix-vl-items").html('<div class="matrix-vl-empty">' + vlEsc(data.message) + '</div>');
$("#matrix-vl-empty,#matrix-vl-more").hide();
return;
}
var now = Date.now();
var $items = $("#matrix-vl-items").empty();
(data.verifications || []).forEach(function(v) {
var typeLabel = v.type === "room" ? "Room verification" : "Device verification";
var sub = "from " + vlEsc(v.userId || "unknown");
if(v.type === "device" && v.deviceId) { sub += " &middot; device " + vlEsc(v.deviceId); }
else if(v.type === "room" && v.roomId) { sub += " &middot; in room " + vlEsc(v.roomId); }
if(v.isSelfVerification) { sub += " &middot; your own session"; }
$items.append($('<div class="matrix-vl-item">')
.attr("data-vid", v.verificationId)
.attr("data-seen", now - (v.ageMs || 0))
.attr("data-expires", (v.expiresInMs != null) ? (now + v.expiresInMs) : "")
.html('<div class="matrix-vl-item-l"><div class="matrix-vl-item-title">' + vlEsc(typeLabel) + '</div>'
+ '<div class="matrix-vl-item-sub">' + sub + '</div></div>'
+ '<div class="matrix-vl-item-r"><div class="matrix-vl-age"></div>'
+ '<div class="matrix-vl-exp"></div></div>'));
});
$("#matrix-vl-empty").toggle(!(data.verifications || []).length);
if(data.hidden > 0) {
$("#matrix-vl-more").text(data.hidden + " older request" + (data.hidden === 1 ? "" : "s") + " hidden.").show();
} else {
$("#matrix-vl-more").hide();
}
vlTick();
};
var vlLoadList = function() {
vlCall({ id: vlId(), action: "list" })
.done(vlRenderList)
.fail(function() { vlRenderList({ result: "error", message: "Request failed — is Node-RED still running?" }); });
};
var vlShowList = function() {
vlClearTimers();
$("#matrix-vl-overlay").data("vlConfirmed", false);
$("#matrix-vl-detailview").hide();
$("#matrix-vl-listview").show();
$("#matrix-vl-back,#matrix-vl-cancel").hide();
$("#matrix-vl-items").html('<div class="matrix-vl-empty">Loading…</div>');
$("#matrix-vl-empty,#matrix-vl-more").hide();
vlLoadList();
vlListTimer = setInterval(vlLoadList, 5000);
vlTickTimer = setInterval(vlTick, 1000);
};
var vlRenderSas = function(sas) {
var $sas = $("#matrix-vl-d-sas").empty();
if(sas.emoji && sas.emoji.length) {
sas.emoji.forEach(function(pair) {
$sas.append('<div class="matrix-vl-emoji"><div class="e">' + vlEsc(pair[0])
+ '</div><div class="n">' + vlEsc(pair[1]) + '</div></div>');
});
} else if(sas.decimal) {
$sas.append('<div style="font-size:24px;font-family:monospace;">' + vlEsc(sas.decimal.join(" ")) + '</div>');
}
};
var vlRenderDetail = function(v) {
var typeLabel = v.type === "room" ? "Room verification" : "Device verification";
var head = vlEsc(typeLabel);
if(v.userId) { head += " &mdash; " + vlEsc(v.userId); }
if(v.deviceId) { head += " (device " + vlEsc(v.deviceId) + ")"; }
$("#matrix-vl-d-head").html(head);
if(v.phase === "done") {
vlClearTimers();
vlState("fa-check-circle", "#3a9a4e", "<b>Verified.</b> This session is now verified.");
$("#matrix-vl-d-sas,#matrix-vl-d-actions").hide();
$("#matrix-vl-cancel").hide();
return;
}
if(v.phase === "cancelled") {
vlClearTimers();
var why = v.cancellationCode ? (" (" + vlEsc(v.cancellationCode) + ")") : "";
vlState("fa-times-circle", "#c9302c", "Verification was cancelled" + why + ".");
$("#matrix-vl-d-sas,#matrix-vl-d-actions").hide();
$("#matrix-vl-cancel").hide();
return;
}
if(v.phase === "gone") {
vlClearTimers();
vlState("fa-exclamation-triangle", "#888", "This verification is no longer available.");
$("#matrix-vl-d-sas,#matrix-vl-d-actions").hide();
$("#matrix-vl-cancel").hide();
return;
}
if($("#matrix-vl-overlay").data("vlConfirmed")) {
$("#matrix-vl-d-sas,#matrix-vl-d-actions").hide();
vlState("fa-spinner fa-spin", "#888", "Waiting for the other device to confirm…");
return;
}
if(v.sas && (v.sas.emoji || v.sas.decimal)) {
vlState("fa-key", "#d18a1b", "Compare these emoji with the other device, then choose below.");
vlRenderSas(v.sas);
$("#matrix-vl-d-sas,#matrix-vl-d-actions").show();
return;
}
$("#matrix-vl-d-sas,#matrix-vl-d-actions").hide();
vlState("fa-spinner fa-spin", "#888", "Waiting for the verification to start…");
};
var vlPollDetail = function() {
vlCall({ id: vlId(), action: "advance", verificationId: vlCurId() })
.done(function(data) {
if(data.result !== "ok") {
vlState("fa-exclamation-triangle", "#c9302c", vlEsc(data.message));
return;
}
vlRenderDetail(data.verification);
})
.fail(function() { vlState("fa-exclamation-triangle", "#c9302c", "Request failed — is Node-RED still running?"); });
};
var vlShowDetail = function(vid) {
vlClearTimers();
$("#matrix-vl-overlay").data("vlCurrentId", vid).data("vlConfirmed", false);
$("#matrix-vl-listview").hide();
$("#matrix-vl-detailview").show();
$("#matrix-vl-d-sas,#matrix-vl-d-actions,#matrix-vl-d-result").hide();
$("#matrix-vl-d-head").text("");
vlState("fa-spinner fa-spin", "#888", "Starting verification…");
$("#matrix-vl-back,#matrix-vl-cancel").show();
vlPollDetail();
vlDetailTimer = setInterval(vlPollDetail, 1500);
};
var vlClose = function() { vlClearTimers(); $("#matrix-vl-overlay").fadeOut(120); };
$("#matrix-vl-x,#matrix-vl-close").on("click", vlClose);
$("#matrix-vl-overlay").on("mousedown", function(e) { if(e.target === this) { vlClose(); } });
$(document).on("keydown.matrixvl", function(e) {
if(e.key === "Escape" && $("#matrix-vl-overlay").is(":visible")) { vlClose(); }
});
$("#matrix-vl-back").on("click", vlShowList);
$("#matrix-vl-items").on("click", ".matrix-vl-item", function() {
vlShowDetail($(this).attr("data-vid"));
});
$("#matrix-vl-cancel").on("click", function() {
vlCall({ id: vlId(), action: "cancel", verificationId: vlCurId() }).always(vlPollDetail);
});
$("#matrix-vl-d-confirm").on("click", function() {
$("#matrix-vl-overlay").data("vlConfirmed", true);
$("#matrix-vl-d-sas,#matrix-vl-d-actions").hide();
vlState("fa-spinner fa-spin", "#888", "Confirming…");
vlCall({ id: vlId(), action: "confirm", verificationId: vlCurId() })
.done(function(data) {
if(data.result !== "ok") {
$("#matrix-vl-overlay").data("vlConfirmed", false);
vlState("fa-exclamation-triangle", "#c9302c", vlEsc(data.message));
$("#matrix-vl-d-actions").show();
}
});
});
$("#matrix-vl-d-mismatch").on("click", function() {
$("#matrix-vl-d-actions").hide();
vlState("fa-spinner fa-spin", "#888", "Cancelling…");
vlCall({ id: vlId(), action: "mismatch", verificationId: vlCurId() }).always(vlPollDetail);
});
$("#matrix-vl-overlay").data("vlShowListFn", vlShowList);
$("#matrix-vl-overlay").data("vlShowDetailFn", vlShowDetail);
}
$("#matrix-verification-list-btn").on("click", function() {
$("#matrix-vl-overlay").data("matrixNodeId", nodeId).fadeIn(120);
$("#matrix-vl-overlay").data("vlShowListFn")();
});
// --- Sessions (modal) ---
// Built once and reused; the node id is stored on the overlay.
if (!document.getElementById("matrix-ss-overlay")) {
$('<style>'
+ '.matrix-ss-overlay{position:fixed;inset:0;z-index:3000;display:none;background:rgba(0,0,0,.45);}'
+ '.matrix-ss-modal{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);'
+ 'width:560px;max-width:92vw;max-height:88vh;display:flex;flex-direction:column;'
+ 'border-radius:6px;overflow:hidden;box-shadow:0 12px 44px rgba(0,0,0,.45);'
+ 'background:var(--red-ui-primary-background,#fff);color:var(--red-ui-primary-text-color,#2a2a2a);}'
+ '.matrix-ss-head{display:flex;justify-content:space-between;align-items:center;'
+ 'padding:12px 16px;font-size:15px;font-weight:bold;'
+ 'background:var(--red-ui-secondary-background,#f3f3f3);'
+ 'border-bottom:1px solid var(--red-ui-secondary-border-color,#ddd);}'
+ '.matrix-ss-x{cursor:pointer;font-size:20px;line-height:1;opacity:.55;}'
+ '.matrix-ss-x:hover{opacity:1;}'
+ '.matrix-ss-body{padding:14px 16px;overflow:auto;}'
+ '.matrix-ss-h{font-weight:bold;font-size:12px;text-transform:uppercase;'
+ 'letter-spacing:.04em;color:var(--red-ui-secondary-text-color,#888);margin:2px 0 8px;}'
+ '.matrix-ss-h.spaced{margin-top:20px;}'
+ '.matrix-ss-item{display:flex;align-items:center;gap:10px;padding:10px 12px;'
+ 'margin-bottom:8px;border-radius:5px;cursor:pointer;'
+ 'border:1px solid var(--red-ui-secondary-border-color,#ddd);'
+ 'background:var(--red-ui-secondary-background,#f7f7f7);}'
+ '.matrix-ss-item:hover{border-color:var(--red-ui-node-border,#999);}'
+ '.matrix-ss-item-l{flex:1;min-width:0;}'
+ '.matrix-ss-item-title{font-weight:bold;word-break:break-word;}'
+ '.matrix-ss-item-sub{font-size:12px;color:var(--red-ui-secondary-text-color,#888);'
+ 'margin-top:2px;word-break:break-word;}'
+ '.matrix-ss-shield{font-size:20px;width:22px;text-align:center;flex-shrink:0;}'
+ '.matrix-ss-box{display:flex;gap:10px;align-items:flex-start;margin:10px 0;'
+ 'padding:10px 12px;border-radius:4px;font-size:13px;line-height:1.45;}'
+ '.matrix-ss-box.ok{background:#e7f4ea;border:1px solid #8fcea5;color:#1e6b33;}'
+ '.matrix-ss-box.err{background:#fde7e9;border:1px solid #e8a0a8;color:#8a1f2b;}'
+ '.matrix-ss-box .fa{font-size:18px;margin-top:1px;}'
+ '.matrix-ss-details div{display:flex;font-size:13px;padding:5px 0;'
+ 'border-bottom:1px solid var(--red-ui-secondary-border-color,#eee);}'
+ '.matrix-ss-details .k{width:130px;flex-shrink:0;color:var(--red-ui-secondary-text-color,#888);}'
+ '.matrix-ss-details .v{word-break:break-all;}'
+ '.matrix-ss-empty,.matrix-ss-more{font-size:13px;'
+ 'color:var(--red-ui-secondary-text-color,#888);padding:6px 2px;}'
+ '.matrix-ss-removelink{color:#c9302c;cursor:pointer;font-weight:bold;}'
+ '.matrix-ss-result{margin-top:12px;padding:10px 12px;border-radius:4px;font-size:13px;}'
+ '.matrix-ss-result.ok{background:#e7f4ea;border:1px solid #8fcea5;color:#1e6b33;}'
+ '.matrix-ss-result.err{background:#fde7e9;border:1px solid #e8a0a8;color:#8a1f2b;}'
+ '.matrix-ss-foot{display:flex;align-items:center;gap:8px;padding:10px 16px;'
+ 'background:var(--red-ui-secondary-background,#f3f3f3);'
+ 'border-top:1px solid var(--red-ui-secondary-border-color,#ddd);}'
+ '</style>').appendTo("head");
$('<div id="matrix-ss-overlay" class="matrix-ss-overlay"><div class="matrix-ss-modal">'
+ '<div class="matrix-ss-head"><span><i class="fa fa-desktop"></i> Sessions</span>'
+ '<span class="matrix-ss-x" id="matrix-ss-x" title="Close">&times;</span></div>'
+ '<div class="matrix-ss-body">'
+ '<div id="matrix-ss-listview">'
+ '<div class="matrix-ss-h">Current session</div>'
+ '<div id="matrix-ss-current"></div>'
+ '<div id="matrix-ss-currentmsg"></div>'
+ '<div class="matrix-ss-h spaced">Other sessions</div>'
+ '<div id="matrix-ss-others"></div>'
+ '<div id="matrix-ss-others-empty" class="matrix-ss-empty" style="display:none;">No other sessions.</div>'
+ '<div id="matrix-ss-more" class="matrix-ss-more" style="display:none;"></div>'
+ '</div>'
+ '<div id="matrix-ss-detailview" style="display:none;">'
+ '<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;">'
+ '<div id="matrix-ss-d-name" style="font-size:15px;font-weight:bold;word-break:break-word;"></div>'
+ '<button type="button" class="red-ui-button" id="matrix-ss-d-rename">Rename</button></div>'
+ '<div id="matrix-ss-d-status"></div>'
+ '<div class="matrix-ss-h spaced">Session details</div>'
+ '<div id="matrix-ss-d-details" class="matrix-ss-details"></div>'
+ '<div id="matrix-ss-d-verifywrap" style="display:none;margin-top:14px;">'
+ '<button type="button" class="red-ui-button" id="matrix-ss-d-verify">Verify this session</button></div>'
+ '<div id="matrix-ss-d-removewrap" style="display:none;margin-top:16px;">'
+ '<span class="matrix-ss-removelink" id="matrix-ss-d-removelink">Remove this session</span>'
+ '<div id="matrix-ss-d-removeconfirm" style="display:none;margin-top:8px;">'
+ '<div style="background:#fdf3e7;border:1px solid #f0c36d;color:#7a5b16;border-radius:4px;'
+ 'padding:8px 10px;font-size:13px;margin-bottom:8px;">Removing a session signs it out. '
+ 'Enter the account password to confirm.</div>'
+ '<input type="password" id="matrix-ss-d-password" placeholder="Account password" '
+ 'style="width:100%;box-sizing:border-box;padding:7px 9px;border-radius:4px;'
+ 'border:1px solid var(--red-ui-form-input-border-color,#ccc);'
+ 'background:var(--red-ui-form-input-background,#fff);color:var(--red-ui-primary-text-color,#2a2a2a);">'
+ '<div style="text-align:right;margin-top:8px;"><button type="button" class="red-ui-button" '
+ 'id="matrix-ss-d-removeconfirmbtn">Confirm removal</button></div>'
+ '</div></div>'
+ '<div id="matrix-ss-d-result" class="matrix-ss-result" style="display:none;"></div>'
+ '</div>'
+ '</div>'
+ '<div class="matrix-ss-foot">'
+ '<button type="button" class="red-ui-button" id="matrix-ss-back" style="display:none;">&larr; Back</button>'
+ '<span style="flex:1;"></span>'
+ '<button type="button" class="red-ui-button" id="matrix-ss-close">Close</button>'
+ '</div></div></div>').appendTo(document.body);
var ssEsc = function(s) { return $("<div>").text(s == null ? "" : String(s)).html(); };
var ssId = function() { return $("#matrix-ss-overlay").data("matrixNodeId"); };
var ssCall = function(body) {
return $.ajax({
url: "matrix-chat/sessions", type: "POST",
contentType: "application/json", data: JSON.stringify(body),
});
};
var ssRelative = function(ts) {
if(!ts) { return "activity unknown"; }
var days = Math.floor((Date.now() - ts) / 86400000);
if(days <= 0) { return "active today"; }
if(days === 1) { return "active yesterday"; }
if(days < 90) { return "active " + days + " days ago"; }
return "inactive for 90+ days";
};
var ssFullDate = function(ts) {
return ts ? new Date(ts).toLocaleString() : "unknown";
};
var ssShield = function(verified) {
return '<i class="fa fa-shield matrix-ss-shield" style="color:'
+ (verified ? '#3a9a4e' : '#c9302c') + ';" title="'
+ (verified ? 'Verified' : 'Not verified') + '"></i>';
};
var ssCard = function(d, isCurrent) {
var sub = (d.verified ? 'Verified' : 'Not verified')
+ ' · ' + ssRelative(d.lastSeenTs)
+ (d.lastSeenIp ? (' · ' + d.lastSeenIp) : '');
return $('<div class="matrix-ss-item">')
.data("device", d).data("isCurrent", isCurrent)
.html(ssShield(d.verified)
+ '<div class="matrix-ss-item-l"><div class="matrix-ss-item-title">'
+ ssEsc(d.displayName || d.deviceId) + '</div>'
+ '<div class="matrix-ss-item-sub">' + ssEsc(sub) + '</div></div>'
+ '<i class="fa fa-angle-right" style="opacity:.5;"></i>');
};
var ssResult = function(ok, text) {
$("#matrix-ss-d-result").removeClass("ok err").addClass(ok ? "ok" : "err").text(text).show();
};
var ssRenderList = function(data) {
if(data.result !== "ok") {
$("#matrix-ss-current").html('<div class="matrix-ss-empty">' + ssEsc(data.message) + '</div>');
$("#matrix-ss-currentmsg,#matrix-ss-others").empty();
$("#matrix-ss-others-empty,#matrix-ss-more").hide();
return;
}
$("#matrix-ss-current").empty().append(ssCard(data.current, true));
$("#matrix-ss-currentmsg").html(data.current.verified
? '<div class="matrix-ss-box ok"><i class="fa fa-shield"></i><div><b>Verified session</b><br>'
+ 'This session is cross-signed and ready for secure messaging.</div></div>'
: '<div class="matrix-ss-box err"><i class="fa fa-shield"></i><div><b>Not verified</b><br>'
+ 'This session is not cross-signed. Use the Set up secure backup &amp; cross-signing '
+ 'button to verify it.</div></div>');
var $others = $("#matrix-ss-others").empty();
(data.others || []).forEach(function(d) { $others.append(ssCard(d, false)); });
$("#matrix-ss-others-empty").toggle(!(data.others || []).length);
if(data.hidden > 0) {
$("#matrix-ss-more").text(data.hidden + " more session" + (data.hidden === 1 ? "" : "s") + " hidden.").show();
} else {
$("#matrix-ss-more").hide();
}
};
var ssShowList = function() {
$("#matrix-ss-detailview").hide();
$("#matrix-ss-listview").show();
$("#matrix-ss-back").hide();
$("#matrix-ss-current").html('<div class="matrix-ss-empty">Loading…</div>');
$("#matrix-ss-currentmsg,#matrix-ss-others").empty();
$("#matrix-ss-others-empty,#matrix-ss-more").hide();
ssCall({ id: ssId(), action: "list" })
.done(ssRenderList)
.fail(function() { ssRenderList({ result: "error", message: "Request failed — is Node-RED still running?" }); });
};
var ssDetailRow = function(k, v) {
return '<div><span class="k">' + ssEsc(k) + '</span><span class="v">' + ssEsc(v) + '</span></div>';
};
var ssShowDetail = function(d, isCurrent) {
$("#matrix-ss-overlay").data("ssDevice", d).data("ssIsCurrent", isCurrent);
$("#matrix-ss-listview").hide();
$("#matrix-ss-detailview").show();
$("#matrix-ss-back").show();
$("#matrix-ss-d-result,#matrix-ss-d-removeconfirm").hide();
$("#matrix-ss-d-name").text(d.displayName || d.deviceId);
$("#matrix-ss-d-status").html(d.verified
? '<div class="matrix-ss-box ok"><i class="fa fa-shield"></i><div><b>Verified session</b><br>'
+ 'This session is ready for secure messaging.</div></div>'
: '<div class="matrix-ss-box err"><i class="fa fa-shield"></i><div><b>Not verified</b><br>'
+ (isCurrent
? 'This session is not cross-signed. Use the Set up secure backup &amp; cross-signing button to verify it.'
: 'Verify this session to confirm it is trusted.')
+ '</div></div>');
$("#matrix-ss-d-details").html(
ssDetailRow("Session ID", d.deviceId)
+ ssDetailRow("Last activity", ssFullDate(d.lastSeenTs))
+ ssDetailRow("IP address", d.lastSeenIp || "unknown"));
$("#matrix-ss-d-verifywrap").toggle(!isCurrent && !d.verified);
$("#matrix-ss-d-removewrap").toggle(!isCurrent);
};
var ssClose = function() { $("#matrix-ss-overlay").fadeOut(120); };
$("#matrix-ss-x,#matrix-ss-close").on("click", ssClose);
$("#matrix-ss-overlay").on("mousedown", function(e) { if(e.target === this) { ssClose(); } });
$(document).on("keydown.matrixss", function(e) {
if(e.key === "Escape" && $("#matrix-ss-overlay").is(":visible")) { ssClose(); }
});
$("#matrix-ss-back").on("click", ssShowList);
$("#matrix-ss-current,#matrix-ss-others").on("click", ".matrix-ss-item", function() {
ssShowDetail($(this).data("device"), $(this).data("isCurrent"));
});
$("#matrix-ss-d-rename").on("click", function() {
var d = $("#matrix-ss-overlay").data("ssDevice");
var name = prompt("Session display name:", d.displayName || "");
if(name === null) { return; }
ssCall({ id: ssId(), action: "rename", deviceId: d.deviceId, displayName: name })
.done(function(r) {
if(r.result !== "ok") { ssResult(false, r.message); return; }
d.displayName = name;
$("#matrix-ss-d-name").text(name || d.deviceId);
ssResult(true, "Session renamed.");
})
.fail(function() { ssResult(false, "Request failed."); });
});
$("#matrix-ss-d-verify").on("click", function() {
var d = $("#matrix-ss-overlay").data("ssDevice");
$("#matrix-ss-d-result").removeClass("ok err").html('<i class="fa fa-spinner fa-spin"></i> Starting verification…').show();
ssCall({ id: ssId(), action: "verify", deviceId: d.deviceId })
.done(function(r) {
if(r.result !== "ok" || !r.verificationId) {
ssResult(false, r.message || "Could not start verification.");
return;
}
// hand off to the verification modal's detail view
$("#matrix-ss-overlay").fadeOut(120);
$("#matrix-vl-overlay").data("matrixNodeId", ssId()).fadeIn(120);
$("#matrix-vl-overlay").data("vlShowDetailFn")(r.verificationId);
})
.fail(function() { ssResult(false, "Request failed."); });
});
$("#matrix-ss-d-removelink").on("click", function() {
$("#matrix-ss-d-password").val("");
$("#matrix-ss-d-removeconfirm").show();
});
$("#matrix-ss-d-removeconfirmbtn").on("click", function() {
var d = $("#matrix-ss-overlay").data("ssDevice");
$("#matrix-ss-d-result").removeClass("ok err").html('<i class="fa fa-spinner fa-spin"></i> Removing session…').show();
ssCall({ id: ssId(), action: "remove", deviceId: d.deviceId, password: $("#matrix-ss-d-password").val() })
.done(function(r) {
if(r.result !== "ok") { ssResult(false, r.message); return; }
ssShowList();
})
.fail(function() { ssResult(false, "Request failed."); });
});
$("#matrix-ss-overlay").data("ssShowListFn", ssShowList);
}
$("#matrix-sessions-btn").on("click", function() {
$("#matrix-ss-overlay").data("matrixNodeId", nodeId).fadeIn(120);
$("#matrix-ss-overlay").data("ssShowListFn")();
});
// --- Login: fetch a fresh access token & device id --- // --- Login: fetch a fresh access token & device id ---
$("#matrix-login-btn").on("click", function() { $("#matrix-login-btn").on("click", function() {
function prettyPrintJson(json) { function prettyPrintJson(json) {
@@ -372,6 +905,24 @@
Sets up cross-signing so the bot's own device shows as verified. The server Sets up cross-signing so the bot's own device shows as verified. The server
configuration must be deployed and connected first. configuration must be deployed and connected first.
</div> </div>
<div class="form-row">
<label><i class="fa fa-check-circle"></i> Verification</label>
<button type="button" class="red-ui-button" id="matrix-verification-list-btn">Pending verification requests</button>
</div>
<div class="form-tips" style="margin-bottom: 12px;">
Review and complete incoming device verification requests without building a flow.
The server configuration must be deployed and connected first.
</div>
<div class="form-row">
<label><i class="fa fa-desktop"></i> Sessions</label>
<button type="button" class="red-ui-button" id="matrix-sessions-btn">Manage sessions</button>
</div>
<div class="form-tips" style="margin-bottom: 12px;">
View the account's logged-in sessions, verify them, or remove ones you don't recognize.
The server configuration must be deployed and connected first.
</div>
</script> </script>
<script type="text/html" data-help-name="matrix-server-config"> <script type="text/html" data-help-name="matrix-server-config">
+250 -2
View File
@@ -382,6 +382,7 @@ module.exports = function(RED) {
return; // already tracked return; // already tracked
} }
node.verificationRequests.set(id, request); node.verificationRequests.set(id, request);
request.__nrSeenAt = Date.now();
let verifierHooked = false; let verifierHooked = false;
const onChange = function() { const onChange = function() {
@@ -399,8 +400,13 @@ module.exports = function(RED) {
emitVerificationUpdate(request, false); emitVerificationUpdate(request, false);
if(request.phase === VerificationPhase.Done || request.phase === VerificationPhase.Cancelled) { if(request.phase === VerificationPhase.Done || request.phase === VerificationPhase.Cancelled) {
request.off(VerificationRequestEvent.Change, onChange); request.off(VerificationRequestEvent.Change, onChange);
node.verificationRequests.delete(id); // Keep the finished request briefly so the config
node.verificationSas.delete(id); // editor's verification list can still report the
// outcome; the /matrix-chat/verification "list"
// action sweeps entries older than 2 minutes.
if(!request.__nrEndedAt) {
request.__nrEndedAt = Date.now();
}
} }
} catch(e) { } catch(e) {
node.error("Verification request handler error: " + e); node.error("Verification request handler error: " + e);
@@ -933,6 +939,248 @@ module.exports = function(RED) {
} }
}); });
/**
* Lists and drives device verification requests for the config editor's
* "Verification" button (the verification list modal). Same flows.write
* protection as the other admin endpoints, so it is not publicly exposed.
*
* Actions (on req.body.id, the server config node):
* - list : the pending verification requests (newest 20)
* - advance : accept / start SAS for one request and return its state
* - confirm : confirm the SAS emoji match
* - mismatch: declare the SAS emoji do not match
* - cancel : cancel the verification
*/
RED.httpAdmin.post(
"/matrix-chat/verification",
RED.auth.needsPermission('flows.write'),
async function(req, res) {
try {
const serverNode = RED.nodes.getNode(req.body.id);
if(!serverNode || !serverNode.matrixClient) {
return res.json({ result: 'error', message: 'Server configuration not found. Save and deploy the server configuration node first.' });
}
if(typeof serverNode.isConnected !== 'function' || !serverNode.isConnected()) {
return res.json({ result: 'error', message: 'The Matrix client is not connected.' });
}
if(!serverNode.matrixClient.getCrypto()) {
return res.json({ result: 'error', message: 'End-to-end encryption is not enabled on this server configuration.' });
}
const { VerificationPhase } = await cryptoApiPromise;
const PHASE_NAMES = { 1: 'unsent', 2: 'requested', 3: 'ready', 4: 'started', 5: 'cancelled', 6: 'done' };
const requests = serverNode.verificationRequests;
const sasMap = serverNode.verificationSas;
const action = req.body.action || 'list';
function safe(fn, fallback) {
try { return fn(); } catch(e) { return fallback; }
}
function detailOf(vid, r) {
const roomId = safe(function(){ return r.roomId; }, null) || null;
const timeout = safe(function(){ return r.timeout; }, null);
const sas = sasMap.get(vid);
return {
verificationId : vid,
phase : PHASE_NAMES[safe(function(){ return r.phase; })] || 'unknown',
userId : safe(function(){ return r.otherUserId; }, null),
deviceId : safe(function(){ return r.otherDeviceId; }, null) || null,
roomId : roomId,
type : roomId ? 'room' : 'device',
isSelfVerification: safe(function(){ return r.isSelfVerification; }, false),
initiatedByMe : safe(function(){ return r.initiatedByMe; }, false),
ageMs : Date.now() - (r.__nrSeenAt || Date.now()),
expiresInMs : (typeof timeout === 'number') ? timeout : null,
cancellationCode : safe(function(){ return r.cancellationCode; }, null),
sas : (sas && sas.sas) ? { emoji: sas.sas.emoji || null, decimal: sas.sas.decimal || null } : null,
};
}
if(action === 'list') {
const now = Date.now();
// sweep finished verifications kept only for recent lookups
for(const entry of Array.from(requests)) {
if(entry[1].__nrEndedAt && (now - entry[1].__nrEndedAt) > 120000) {
requests.delete(entry[0]);
sasMap.delete(entry[0]);
}
}
let items = [];
for(const entry of requests) {
const detail = detailOf(entry[0], entry[1]);
if(detail.phase === 'done' || detail.phase === 'cancelled' || detail.phase === 'unsent') {
continue;
}
items.push(detail);
}
items.sort(function(a, b) { return a.ageMs - b.ageMs; }); // newest first
return res.json({
result: 'ok',
refreshSeconds: 5,
total: items.length,
hidden: Math.max(0, items.length - 20),
verifications: items.slice(0, 20),
});
}
// remaining actions operate on a single verification
const request = requests.get(req.body.verificationId);
if(!request) {
return res.json({ result: 'ok', verification: { verificationId: req.body.verificationId, phase: 'gone' } });
}
if(action === 'advance') {
try {
const phase = safe(function(){ return request.phase; });
if(phase === VerificationPhase.Requested
&& !safe(function(){ return request.initiatedByMe; }, false)
&& !safe(function(){ return request.accepting; }, false)) {
await request.accept();
} else if(phase === VerificationPhase.Ready && !safe(function(){ return request.verifier; })) {
await request.startVerification("m.sas.v1");
}
const verifier = safe(function(){ return request.verifier; });
if(verifier && !request.__nrVerifyCalled) {
request.__nrVerifyCalled = true;
verifier.verify().catch(function(){ /* completes/cancels elsewhere */ });
}
} catch(e) {
serverNode.warn("Verification advance error: " + e);
}
return res.json({ result: 'ok', verification: detailOf(req.body.verificationId, request) });
}
if(action === 'confirm' || action === 'mismatch') {
const sas = sasMap.get(req.body.verificationId);
if(!sas) {
return res.json({ result: 'error', message: 'This verification has no SAS awaiting confirmation yet.' });
}
if(action === 'confirm') {
await sas.confirm();
} else {
sas.mismatch();
}
return res.json({ result: 'ok', verification: detailOf(req.body.verificationId, request) });
}
if(action === 'cancel') {
await request.cancel();
return res.json({ result: 'ok', verification: detailOf(req.body.verificationId, request) });
}
return res.json({ result: 'error', message: 'Unknown action: ' + action });
} catch(error) {
res.json({ result: 'error', message: String(error && error.message || error) });
}
});
/**
* Session (device) management for the config editor's "Sessions" button.
* Same flows.write protection as the other admin endpoints.
*
* Actions (on req.body.id, the server config node):
* - list : the account's sessions (current + others) with verification state
* - rename : set a session's display name
* - remove : delete a session (requires the account password)
* - verify : start verifying a session; returns a verificationId to hand
* off to the verification modal
*/
RED.httpAdmin.post(
"/matrix-chat/sessions",
RED.auth.needsPermission('flows.write'),
async function(req, res) {
try {
const serverNode = RED.nodes.getNode(req.body.id);
if(!serverNode || !serverNode.matrixClient) {
return res.json({ result: 'error', message: 'Server configuration not found. Save and deploy the server configuration node first.' });
}
if(typeof serverNode.isConnected !== 'function' || !serverNode.isConnected()) {
return res.json({ result: 'error', message: 'The Matrix client is not connected.' });
}
const client = serverNode.matrixClient;
const crypto = client.getCrypto();
if(!crypto) {
return res.json({ result: 'error', message: 'End-to-end encryption is not enabled on this server configuration.' });
}
const action = req.body.action || 'list';
if(action === 'list') {
const currentDeviceId = client.getDeviceId();
const devices = (await client.getDevices()).devices || [];
const enriched = await Promise.all(devices.map(async function(d) {
let verified = false;
try {
const status = await crypto.getDeviceVerificationStatus(serverNode.userId, d.device_id);
verified = !!(status && status.isVerified());
} catch(e) { /* unknown - treat as unverified */ }
return {
deviceId : d.device_id,
displayName : d.display_name || null,
lastSeenTs : d.last_seen_ts || null,
lastSeenIp : d.last_seen_ip || null,
verified : verified,
};
}));
const current = enriched.find(function(d){ return d.deviceId === currentDeviceId; })
|| { deviceId: currentDeviceId, displayName: null, lastSeenTs: null, lastSeenIp: null, verified: false };
let others = enriched.filter(function(d){ return d.deviceId !== currentDeviceId; });
others.sort(function(a, b){ return (b.lastSeenTs || 0) - (a.lastSeenTs || 0); });
return res.json({
result: 'ok',
current: current,
others: others.slice(0, 50),
hidden: Math.max(0, others.length - 50),
});
}
const deviceId = req.body.deviceId;
if(!deviceId) {
return res.json({ result: 'error', message: 'A deviceId is required.' });
}
if(action === 'rename') {
await client.setDeviceDetails(deviceId, { display_name: req.body.displayName || '' });
return res.json({ result: 'ok' });
}
if(action === 'remove') {
const password = req.body.password;
try {
await client.deleteDevice(deviceId);
} catch(e) {
// deleting a device is user-interactive-auth protected
if(e && e.httpStatus === 401 && e.data && e.data.flows) {
if(!password) {
return res.json({ result: 'error', message: 'The account password is required to remove a session.' });
}
await client.deleteDevice(deviceId, {
type: 'm.login.password',
identifier: { type: 'm.id.user', user: serverNode.userId },
password: password,
session: e.data.session,
});
} else {
throw e;
}
}
serverNode.log("Removed session " + deviceId);
return res.json({ result: 'ok', message: 'Session removed.' });
}
if(action === 'verify') {
const request = await crypto.requestDeviceVerification(serverNode.userId, deviceId);
if(typeof serverNode.trackVerificationRequest === 'function') {
serverNode.trackVerificationRequest(request);
}
return res.json({ result: 'ok', verificationId: request.transactionId || null });
}
return res.json({ result: 'error', message: 'Unknown action: ' + action });
} catch(error) {
res.json({ result: 'error', message: String(error && error.message || error) });
}
});
function upgradeDirectoryIfNecessary(node, storageDir) { function upgradeDirectoryIfNecessary(node, storageDir) {
let oldStorageDir = './matrix-local-storage', let oldStorageDir = './matrix-local-storage',
oldStorageDir2 = './matrix-client-storage'; oldStorageDir2 = './matrix-client-storage';