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
+250 -2
View File
@@ -382,6 +382,7 @@ module.exports = function(RED) {
return; // already tracked
}
node.verificationRequests.set(id, request);
request.__nrSeenAt = Date.now();
let verifierHooked = false;
const onChange = function() {
@@ -399,8 +400,13 @@ module.exports = function(RED) {
emitVerificationUpdate(request, false);
if(request.phase === VerificationPhase.Done || request.phase === VerificationPhase.Cancelled) {
request.off(VerificationRequestEvent.Change, onChange);
node.verificationRequests.delete(id);
node.verificationSas.delete(id);
// Keep the finished request briefly so the config
// 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) {
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) {
let oldStorageDir = './matrix-local-storage',
oldStorageDir2 = './matrix-client-storage';