feat: rework Electron desktop app to run standalone-first with optional two-way sync to a remote Termix server

This commit is contained in:
LukeGus
2026-07-21 01:27:03 -05:00
parent f44d09eef7
commit 08825c256d
77 changed files with 3658 additions and 929 deletions
+9
View File
@@ -226,6 +226,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/sync(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/termix-id(/.*)?$ { location ~ ^/termix-id(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
+9
View File
@@ -215,6 +215,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
} }
location ~ ^/sync(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
location ~ ^/termix-id(/.*)?$ { location ~ ^/termix-id(/.*)?$ {
proxy_pass http://127.0.0.1:30001; proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1; proxy_http_version 1.1;
+67 -1
View File
@@ -20,6 +20,7 @@ const net = require("net");
const { URL } = require("url"); const { URL } = require("url");
const { fork, spawn } = require("child_process"); const { fork, spawn } = require("child_process");
const WebSocket = require("ws"); const WebSocket = require("ws");
const remoteSync = require("./remote-sync.cjs");
// Portable mode: if a `.portable` marker exists next to the executable, // Portable mode: if a `.portable` marker exists next to the executable,
// store all data in a `data` folder beside the exe instead of %APPDATA%. // store all data in a `data` folder beside the exe instead of %APPDATA%.
@@ -852,6 +853,7 @@ function startBackendServer() {
NODE_ENV: "production", NODE_ENV: "production",
ELECTRON_EMBEDDED: "true", ELECTRON_EMBEDDED: "true",
PORT: "30001", PORT: "30001",
VERSION: app.getVersion(),
}, },
stdio: ["pipe", "pipe", "pipe", "ipc"], stdio: ["pipe", "pipe", "pipe", "ipc"],
}); });
@@ -1335,7 +1337,6 @@ ipcMain.handle("get-embedded-server-status", () => {
return { return {
running: running:
backendProcess !== null && !backendProcess.killed && !backendStartFailed, backendProcess !== null && !backendProcess.killed && !backendStartFailed,
embedded: !isDev,
dataDir: isDev ? null : getBackendDataDir(), dataDir: isDev ? null : getBackendDataDir(),
}; };
}); });
@@ -1442,6 +1443,70 @@ ipcMain.handle("save-server-config", (event, config) => {
} }
}); });
// --- Remote sync (optional desktop <-> self-hosted server sync) ---
ipcMain.handle("get-desktop-settings", () => {
return remoteSync.getDesktopSettings();
});
ipcMain.handle("save-desktop-settings", (_event, settings) => {
return remoteSync.saveDesktopSettings(settings);
});
ipcMain.handle("get-remote-sync-config", () => {
return remoteSync.getRemoteSyncConfig();
});
ipcMain.handle("save-remote-sync-config", (_event, config) => {
return remoteSync.saveRemoteSyncConfig(config);
});
ipcMain.handle("clear-remote-sync-config", async () => {
const result = remoteSync.clearRemoteSyncConfig();
remoteSync.clearRemoteSyncJwt();
remoteSync.getRemoteSyncEngine()?.updateStatus({
connected: false,
syncing: false,
needsReauth: false,
lastError: null,
});
return result;
});
ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
const result = remoteSync.saveRemoteSyncJwt(token);
if (result.success) {
remoteSync.getRemoteSyncEngine()?.updateStatus({
connected: true,
needsReauth: false,
lastError: null,
});
remoteSync.getRemoteSyncEngine()?.syncNow();
}
return result;
});
ipcMain.handle("get-remote-sync-jwt", () => {
return remoteSync.getRemoteSyncJwt();
});
ipcMain.handle("clear-remote-sync-jwt", () => {
return remoteSync.clearRemoteSyncJwt();
});
ipcMain.handle("get-remote-sync-status", () => {
return remoteSync.getRemoteSyncEngine()?.status || null;
});
ipcMain.handle("remote-sync-now", async () => {
return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null;
});
ipcMain.handle("notify-local-login", (_event, token) => {
remoteSync.getRemoteSyncEngine()?.setLocalJwt(token);
return { success: true };
});
function getC2STunnelConfigPath() { function getC2STunnelConfigPath() {
return path.join(app.getPath("userData"), "c2s-tunnels.json"); return path.join(app.getPath("userData"), "c2s-tunnels.json");
} }
@@ -2974,6 +3039,7 @@ app.whenReady().then(async () => {
createTray(); createTray();
createWindow(); createWindow();
remoteSync.initRemoteSync(() => mainWindow);
logToFile("=== Startup complete ==="); logToFile("=== Startup complete ===");
}); });
+7
View File
@@ -31,6 +31,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
startC2SAutoStartTunnels: () => startC2SAutoStartTunnels: () =>
ipcRenderer.invoke("start-c2s-autostart-tunnels"), ipcRenderer.invoke("start-c2s-autostart-tunnels"),
onRemoteSyncStatusChanged: (callback) => {
const listener = (_event, status) => callback(status);
ipcRenderer.on("remote-sync-status-changed", listener);
return () =>
ipcRenderer.removeListener("remote-sync-status-changed", listener);
},
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"), clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
getSessionCookie: (name, targetUrl) => getSessionCookie: (name, targetUrl) =>
ipcRenderer.invoke("get-session-cookie", name, targetUrl), ipcRenderer.invoke("get-session-cookie", name, targetUrl),
+473
View File
@@ -0,0 +1,473 @@
// Remote sync engine for the desktop app's optional connection to a
// self-hosted Termix server. Runs entirely in the Electron main process:
// - Holds the remote JWT (safeStorage-encrypted on disk, never exposed to
// the renderer's localStorage) and the local embedded backend's JWT
// (cached in memory only, handed over by the renderer at local-login
// time via notify-local-login).
// - On a timer, pulls + pushes each synced entity type between the
// embedded backend (always localhost:30001) and the configured remote
// server, reconciling by syncId with last-write-wins on updatedAt, and
// propagating tombstones (deletions) in both directions.
// - Pushes connection/sync status to the renderer via IPC so the Settings
// UI and a global banner can reflect it without polling.
const { app, safeStorage } = require("electron");
const fs = require("fs");
const path = require("path");
const SYNCED_ENTITY_TYPES = [
"hosts",
"sshCredentials",
"sshFolders",
"snippets",
"snippetFolders",
"vaultProfiles",
"dashboardServiceLinks",
"homepageItems",
];
const SYNC_INTERVAL_MS = 90 * 1000;
const EMBEDDED_BASE_URL = "http://127.0.0.1:30001";
function dataPath(filename) {
return path.join(app.getPath("userData"), filename);
}
function readJson(filePath, fallback) {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return fallback;
}
}
function writeJson(filePath, value) {
const userDataPath = app.getPath("userData");
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
}
function getDesktopSettingsPath() {
return dataPath("desktop-settings.json");
}
function getRemoteSyncConfigPath() {
return dataPath("remote-sync-config.json");
}
function getRemoteSyncCredentialPath() {
return dataPath("remote-sync-credential.json");
}
function getRemoteSyncStatePath() {
return dataPath("remote-sync-state.json");
}
function getDesktopSettings() {
return readJson(getDesktopSettingsPath(), {
defaultConnectionOrigin: "local",
});
}
function saveDesktopSettings(settings) {
writeJson(getDesktopSettingsPath(), settings);
return { success: true };
}
function getRemoteSyncConfig() {
return readJson(getRemoteSyncConfigPath(), null);
}
function saveRemoteSyncConfig(config) {
writeJson(getRemoteSyncConfigPath(), config);
return { success: true };
}
function clearRemoteSyncConfig() {
try {
fs.unlinkSync(getRemoteSyncConfigPath());
} catch {
// already absent
}
return { success: true };
}
function getSafeStorageAvailable() {
try {
return safeStorage.isEncryptionAvailable();
} catch {
return false;
}
}
function saveRemoteSyncJwt(token) {
if (!getSafeStorageAvailable()) {
return { success: false, error: "Encryption unavailable on this system" };
}
writeJson(getRemoteSyncCredentialPath(), {
encrypted: true,
value: safeStorage.encryptString(token).toString("base64"),
obtainedAt: new Date().toISOString(),
});
return { success: true };
}
function getRemoteSyncJwt() {
const record = readJson(getRemoteSyncCredentialPath(), null);
if (!record?.encrypted || !getSafeStorageAvailable()) return null;
try {
return safeStorage.decryptString(Buffer.from(record.value, "base64"));
} catch {
return null;
}
}
function clearRemoteSyncJwt() {
try {
fs.unlinkSync(getRemoteSyncCredentialPath());
} catch {
// already absent
}
return { success: true };
}
function decodeJwtExpiry(token) {
try {
const payloadB64 = token.split(".")[1];
const payload = JSON.parse(
Buffer.from(payloadB64, "base64").toString("utf8"),
);
return typeof payload.exp === "number" ? payload.exp * 1000 : null;
} catch {
return null;
}
}
function isJwtExpiredOrExpiringSoon(token, marginMs = 60 * 1000) {
const expiresAt = decodeJwtExpiry(token);
if (expiresAt === null) return false;
return Date.now() + marginMs >= expiresAt;
}
class RemoteSyncEngine {
constructor(getMainWindow) {
this.getMainWindow = getMainWindow;
this.localJwt = null;
this.timer = null;
this.syncing = false;
this.status = {
connected: false,
syncing: false,
lastSyncedAt: null,
lastError: null,
needsReauth: false,
};
}
setLocalJwt(token) {
this.localJwt = token || null;
}
emitStatus() {
const win = this.getMainWindow?.();
if (!win || win.isDestroyed()) return;
win.webContents.send("remote-sync-status-changed", this.status);
}
updateStatus(patch) {
this.status = { ...this.status, ...patch };
this.emitStatus();
}
start() {
const config = getRemoteSyncConfig();
this.status.connected = !!config?.serverUrl;
if (this.timer) clearInterval(this.timer);
this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS);
if (config?.serverUrl) {
// Fire an initial sync shortly after startup rather than waiting a
// full interval, but don't block app boot on it.
setTimeout(() => this.syncNow(), 5000);
}
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
async syncNow() {
if (this.syncing) return this.status;
const config = getRemoteSyncConfig();
if (!config?.serverUrl) {
this.updateStatus({ connected: false, syncing: false });
return this.status;
}
const remoteJwt = getRemoteSyncJwt();
if (!remoteJwt) {
this.updateStatus({
connected: true,
syncing: false,
needsReauth: true,
lastError: "Not signed in to remote server",
});
return this.status;
}
if (isJwtExpiredOrExpiringSoon(remoteJwt)) {
this.updateStatus({
connected: true,
syncing: false,
needsReauth: true,
lastError: "Remote session expired",
});
return this.status;
}
if (!this.localJwt) {
// Local login hasn't handed us a token yet (e.g. very early after
// boot) -- skip this tick rather than fail loudly.
return this.status;
}
this.syncing = true;
this.updateStatus({ connected: true, syncing: true, lastError: null });
try {
const state = readJson(getRemoteSyncStatePath(), { entities: {} });
let sawAuthFailure = false;
for (const entityType of SYNCED_ENTITY_TYPES) {
const entityState = state.entities[entityType] || {
lastPulledAt: null,
lastPushedAt: null,
};
const result = await this.syncEntity({
entityType,
remoteBaseUrl: config.serverUrl.replace(/\/$/, ""),
remoteJwt,
since: entityState.lastPulledAt,
});
if (result.authFailure) {
sawAuthFailure = true;
break;
}
state.entities[entityType] = {
lastPulledAt: result.syncedAt,
lastPushedAt: result.syncedAt,
};
}
if (sawAuthFailure) {
this.updateStatus({
syncing: false,
needsReauth: true,
lastError: "Remote server rejected the session",
});
return this.status;
}
writeJson(getRemoteSyncStatePath(), state);
writeJson(getRemoteSyncConfigPath(), {
...config,
lastSyncedAt: new Date().toISOString(),
lastSyncStatus: "ok",
lastSyncError: null,
});
this.updateStatus({
connected: true,
syncing: false,
needsReauth: false,
lastSyncedAt: new Date().toISOString(),
lastError: null,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
writeJson(getRemoteSyncConfigPath(), {
...config,
lastSyncStatus: "error",
lastSyncError: message,
});
this.updateStatus({ syncing: false, lastError: message });
} finally {
this.syncing = false;
}
return this.status;
}
async fetchJson(url, token, options = {}) {
const res = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(options.headers || {}),
},
});
if (res.status === 401 || res.status === 403) {
const err = new Error(`Auth failed (${res.status})`);
err.authFailure = true;
throw err;
}
if (!res.ok) {
throw new Error(`Request failed (${res.status}): ${url}`);
}
return res.json();
}
async pullSide(baseUrl, token, entityType, since) {
const url = `${baseUrl}/sync/${entityType}${since ? `?since=${encodeURIComponent(since)}` : ""}`;
const data = await this.fetchJson(url, token);
return data.rows || [];
}
async pullTombstones(baseUrl, token, entityType, since) {
const url = `${baseUrl}/sync/${entityType}/tombstones${since ? `?since=${encodeURIComponent(since)}` : ""}`;
const data = await this.fetchJson(url, token);
return data.tombstones || [];
}
async pushRow(baseUrl, token, entityType, row) {
await this.fetchJson(`${baseUrl}/sync/${entityType}`, token, {
method: "POST",
body: JSON.stringify({ row }),
});
}
async pushTombstone(baseUrl, token, entityType, syncId) {
await this.fetchJson(`${baseUrl}/sync/tombstones`, token, {
method: "POST",
body: JSON.stringify({ entityType, syncId }),
});
}
async syncEntity({ entityType, remoteBaseUrl, remoteJwt, since }) {
const syncedAt = new Date().toISOString();
try {
const [localRows, remoteRows, localTombstones, remoteTombstones] =
await Promise.all([
this.pullSide(EMBEDDED_BASE_URL, this.localJwt, entityType, since),
this.pullSide(remoteBaseUrl, remoteJwt, entityType, since),
this.pullTombstones(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
since,
),
this.pullTombstones(remoteBaseUrl, remoteJwt, entityType, since),
]);
const tombstonedSyncIds = new Set([
...localTombstones.map((t) => t.syncId),
...remoteTombstones.map((t) => t.syncId),
]);
const localBySyncId = new Map(
localRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
);
const remoteBySyncId = new Map(
remoteRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
);
const allSyncIds = new Set([
...localBySyncId.keys(),
...remoteBySyncId.keys(),
]);
for (const syncId of allSyncIds) {
if (tombstonedSyncIds.has(syncId)) continue;
const localRow = localBySyncId.get(syncId);
const remoteRow = remoteBySyncId.get(syncId);
if (localRow && !remoteRow) {
await this.pushRow(remoteBaseUrl, remoteJwt, entityType, localRow);
} else if (remoteRow && !localRow) {
await this.pushRow(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
remoteRow,
);
} else if (localRow && remoteRow) {
const localUpdatedAt = new Date(localRow.updatedAt || 0).getTime();
const remoteUpdatedAt = new Date(remoteRow.updatedAt || 0).getTime();
if (localUpdatedAt > remoteUpdatedAt) {
await this.pushRow(remoteBaseUrl, remoteJwt, entityType, localRow);
} else if (remoteUpdatedAt > localUpdatedAt) {
await this.pushRow(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
remoteRow,
);
}
}
}
// Apply tombstones to whichever side hasn't already deleted the row.
for (const tombstone of localTombstones) {
if (remoteBySyncId.has(tombstone.syncId)) {
await this.pushTombstone(
remoteBaseUrl,
remoteJwt,
entityType,
tombstone.syncId,
);
}
}
for (const tombstone of remoteTombstones) {
if (localBySyncId.has(tombstone.syncId)) {
await this.pushTombstone(
EMBEDDED_BASE_URL,
this.localJwt,
entityType,
tombstone.syncId,
);
}
}
return { syncedAt };
} catch (error) {
if (error?.authFailure) {
return { syncedAt, authFailure: true };
}
throw error;
}
}
}
let engine = null;
function initRemoteSync(getMainWindow) {
engine = new RemoteSyncEngine(getMainWindow);
engine.start();
return engine;
}
function getRemoteSyncEngine() {
return engine;
}
module.exports = {
initRemoteSync,
getRemoteSyncEngine,
getDesktopSettings,
saveDesktopSettings,
getRemoteSyncConfig,
saveRemoteSyncConfig,
clearRemoteSyncConfig,
saveRemoteSyncJwt,
getRemoteSyncJwt,
clearRemoteSyncJwt,
isJwtExpiredOrExpiringSoon,
decodeJwtExpiry,
};
+28 -15
View File
@@ -39,6 +39,19 @@ const nanHeaderPatched = patchFile(path.join(nanDir, "nan.h"), [
# define __builtin_frame_address(level) _AddressOfReturnAddress() # define __builtin_frame_address(level) _AddressOfReturnAddress()
#endif #endif
// v8::External::New()/->Value() gained a mandatory ExternalPointerTypeTag
// argument in V8 15 (Electron 43+). Plain Node (V8 <= 13.x as of Node 24)
// still uses the old 2-arg signatures, so this must be conditional rather
// than assumed - a build can target either header set.
#include <v8-version.h>
#if defined(V8_MAJOR_VERSION) && V8_MAJOR_VERSION >= 15
# define NAN_EXTERNAL_TAG_ARG , static_cast<v8::ExternalPointerTypeTag>(0)
# define NAN_EXTERNAL_TAG_PARAM static_cast<v8::ExternalPointerTypeTag>(0)
#else
# define NAN_EXTERNAL_TAG_ARG
# define NAN_EXTERNAL_TAG_PARAM
#endif
#define NODE_0_10_MODULE_VERSION 11`, #define NODE_0_10_MODULE_VERSION 11`,
}, },
]); ]);
@@ -63,23 +76,24 @@ const bindingPatched = patchFile(bindingPath, [
}, },
]); ]);
// 2. nan_implementation_12_inl.h: replace v8::External::New() with the 3-arg form. // 2. nan_implementation_12_inl.h: replace v8::External::New() with a form that
// Electron 42 / V8 13+ requires an ExternalPointerTypeTag as the third argument. // passes NAN_EXTERNAL_TAG_ARG - a macro (defined in the nan.h patch above)
// that expands to the ExternalPointerTypeTag argument only when the target
// V8 headers actually declare it (V8 15+ / Electron 43+).
const implPath = path.join(nanDir, "nan_implementation_12_inl.h"); const implPath = path.join(nanDir, "nan_implementation_12_inl.h");
let implPatched = false; let implPatched = false;
if (fs.existsSync(implPath)) { if (fs.existsSync(implPath)) {
let src = fs.readFileSync(implPath, "utf8"); let src = fs.readFileSync(implPath, "utf8");
const before = src; const before = src;
const TAG = "static_cast<v8::ExternalPointerTypeTag>(0)"; if (!src.includes("NAN_EXTERNAL_TAG_ARG")) {
if (!src.includes(TAG)) {
src = src.replace( src = src.replace(
/v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value\)/g, /v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value(?:,\s*static_cast<v8::ExternalPointerTypeTag>\(0\))?\)/g,
`v8::External::New(v8::Isolate::GetCurrent(), value, ${TAG})`, `v8::External::New(v8::Isolate::GetCurrent(), value NAN_EXTERNAL_TAG_ARG)`,
); );
src = src.replace( src = src.replace(
/v8::External::New\(isolate,\s*reinterpret_cast<void \*>\(callback\)\)/g, /v8::External::New\(isolate,\s*reinterpret_cast<void \*>\(callback\)(?:,\s*static_cast<v8::ExternalPointerTypeTag>\(0\))?\)/g,
`v8::External::New(isolate, reinterpret_cast<void *>(callback), ${TAG})`, `v8::External::New(isolate, reinterpret_cast<void *>(callback) NAN_EXTERNAL_TAG_ARG)`,
); );
} }
@@ -89,20 +103,19 @@ if (fs.existsSync(implPath)) {
} }
} }
// 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(tag) on v8::External. // 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(NAN_EXTERNAL_TAG_PARAM)
// The new API requires an ExternalPointerTypeTag argument. // on v8::External, same conditional-tag reasoning as above.
const callbacksPath = path.join(nanDir, "nan_callbacks_12_inl.h"); const callbacksPath = path.join(nanDir, "nan_callbacks_12_inl.h");
let callbacksPatched = false; let callbacksPatched = false;
if (fs.existsSync(callbacksPath)) { if (fs.existsSync(callbacksPath)) {
let src = fs.readFileSync(callbacksPath, "utf8"); let src = fs.readFileSync(callbacksPath, "utf8");
const before = src; const before = src;
const TAG = "static_cast<v8::ExternalPointerTypeTag>(0)"; if (!src.includes("NAN_EXTERNAL_TAG_PARAM")) {
if (!src.includes(TAG)) { // Pattern: .As<v8::External>()->Value()) or ->Value(<old hardcoded tag>))
// Pattern: .As<v8::External>()->Value()) — always followed by ))
src = src.replace( src = src.replace(
/\.As<v8::External>\(\)->Value\(\)\)/g, /\.As<v8::External>\(\)->Value\((?:static_cast<v8::ExternalPointerTypeTag>\(0\))?\)\)/g,
`.As<v8::External>()->Value(${TAG}))`, `.As<v8::External>()->Value(NAN_EXTERNAL_TAG_PARAM))`,
); );
} }
+2 -2
View File
@@ -47,7 +47,7 @@ const patches = [
], ],
[ [
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}', '_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}', "_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
], ],
], ],
}, },
@@ -72,7 +72,7 @@ const patches = [
], ],
[ [
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}', '_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}', "_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
], ],
], ],
}, },
+2
View File
@@ -23,6 +23,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js"; import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
import vaultRoutes from "./routes/vault.js"; import vaultRoutes from "./routes/vault.js";
import alertRulesRoutes from "./routes/alert-rules-routes.js"; import alertRulesRoutes from "./routes/alert-rules-routes.js";
import syncRoutes from "./routes/sync.js";
import { createCorsMiddleware } from "../utils/cors-config.js"; import { createCorsMiddleware } from "../utils/cors-config.js";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@@ -1749,6 +1750,7 @@ registerAuditLogRoutes(app, authenticateJWT);
registerTailscaleRoutes(app, authenticateJWT); registerTailscaleRoutes(app, authenticateJWT);
app.use("/vault", vaultRoutes); app.use("/vault", vaultRoutes);
app.use("/", alertRulesRoutes); app.use("/", alertRulesRoutes);
app.use("/sync", syncRoutes);
const frontendDistPaths = [ const frontendDistPaths = [
path.join(__dirname, "../../../dist"), path.join(__dirname, "../../../dist"),
+106 -3
View File
@@ -726,12 +726,16 @@ const addColumnIfNotExists = (
sqlite.exec(`ALTER TABLE ${table} sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN "${column}" ${definition};`); ADD COLUMN "${column}" ${definition};`);
} catch (alterError) { } catch (alterError) {
databaseLogger.warn(`Failed to add column ${column} to ${table}`, { const message =
alterError instanceof Error ? alterError.message : String(alterError);
databaseLogger.warn(
`Failed to add column ${column} to ${table}: ${message}`,
{
operation: "schema_migration", operation: "schema_migration",
table, table,
column, column,
error: alterError, },
}); );
} }
} }
}; };
@@ -1489,6 +1493,7 @@ const migrateSchema = () => {
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" }, { column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
{ column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" }, { column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
{ column: "allow_session_sharing", sql: "ALTER TABLE ssh_data ADD COLUMN allow_session_sharing INTEGER NOT NULL DEFAULT 1" }, { column: "allow_session_sharing", sql: "ALTER TABLE ssh_data ADD COLUMN allow_session_sharing INTEGER NOT NULL DEFAULT 1" },
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
]; ];
for (const migration of sshDataMigrations) { for (const migration of sshDataMigrations) {
@@ -2393,6 +2398,104 @@ const migrateSchema = () => {
} }
} }
// --- sync begin ---
// Stable per-row identity used to match rows across two independently-
// seeded databases (the embedded desktop backend and a connected remote
// server) during sync. Local autoincrement ids collide across instances,
// so a randomly-generated id is the join key instead. SQLite refuses a
// non-constant DEFAULT (e.g. randomblob()) on ALTER TABLE ADD COLUMN for
// tables with existing constraints ("Cannot add a column with
// non-constant default"), so the column is added as plain nullable TEXT;
// repositories set syncId explicitly on insert going forward, and
// existing rows are backfilled by the UPDATE loop below.
addColumnIfNotExists("ssh_data", "sync_id", "TEXT");
addColumnIfNotExists("ssh_credentials", "sync_id", "TEXT");
addColumnIfNotExists("ssh_folders", "sync_id", "TEXT");
addColumnIfNotExists("snippets", "sync_id", "TEXT");
addColumnIfNotExists("snippet_folders", "sync_id", "TEXT");
addColumnIfNotExists("vault_profiles", "sync_id", "TEXT");
addColumnIfNotExists("dashboard_service_links", "sync_id", "TEXT");
// SQLite also rejects NOT NULL DEFAULT CURRENT_TIMESTAMP here for the same
// "non-constant default" reason -- add nullable, then backfill from
// created_at below and rely on the repository layer to keep it current.
addColumnIfNotExists("dashboard_service_links", "updated_at", "TEXT");
try {
sqlite.exec(
"UPDATE dashboard_service_links SET updated_at = created_at WHERE updated_at IS NULL",
);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
databaseLogger.warn(
`Failed to backfill dashboard_service_links.updated_at: ${message}`,
{ operation: "schema_migration", table: "dashboard_service_links" },
);
}
addColumnIfNotExists("homepage_items", "sync_id", "TEXT");
const syncIdTables = [
"ssh_data",
"ssh_credentials",
"ssh_folders",
"snippets",
"snippet_folders",
"vault_profiles",
"dashboard_service_links",
"homepage_items",
];
for (const table of syncIdTables) {
try {
const result = sqlite
.prepare(
`UPDATE ${table} SET sync_id = lower(hex(randomblob(16))) WHERE sync_id IS NULL`,
)
.run();
if (result.changes > 0) {
databaseLogger.info(
`Backfilled sync_id for ${result.changes} row(s) in ${table}`,
{ operation: "sync_id_backfill", table },
);
}
sqlite.exec(
`CREATE UNIQUE INDEX IF NOT EXISTS idx_${table}_sync_id ON ${table}(sync_id)`,
);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
databaseLogger.warn(
`Failed to backfill sync_id for ${table}: ${message}`,
{
operation: "sync_id_backfill",
table,
},
);
}
}
try {
sqlite.prepare("SELECT id FROM sync_tombstones LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS sync_tombstones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
entity_type TEXT NOT NULL,
sync_id TEXT NOT NULL,
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
sqlite.exec(
"CREATE INDEX IF NOT EXISTS idx_sync_tombstones_user_entity ON sync_tombstones(user_id, entity_type)",
);
} catch (createError) {
databaseLogger.warn("Failed to create sync_tombstones table", {
operation: "schema_migration",
error: createError,
});
}
}
// --- sync end ---
databaseLogger.success("Schema migration completed", { databaseLogger.success("Schema migration completed", {
operation: "schema_migration", operation: "schema_migration",
}); });
+38
View File
@@ -240,6 +240,12 @@ export const hosts = sqliteTable("ssh_data", {
socks5Password: text("socks5_password"), socks5Password: text("socks5_password"),
socks5ProxyChain: text("socks5_proxy_chain"), socks5ProxyChain: text("socks5_proxy_chain"),
// null = use the desktop app's global default; "local" | "remote" pins
// this specific host's SSH/Docker-console/Serial connections to originate
// from the embedded local backend or a connected remote sync server.
// Ignored for rdp/vnc/telnet, which always require the remote server.
connectionOrigin: text("connection_origin"),
macAddress: text("mac_address"), macAddress: text("mac_address"),
wolBroadcastAddress: text("wol_broadcast_address"), wolBroadcastAddress: text("wol_broadcast_address"),
portKnockSequence: text("port_knock_sequence"), portKnockSequence: text("port_knock_sequence"),
@@ -251,6 +257,11 @@ export const hosts = sqliteTable("ssh_data", {
hostKeyLastVerified: text("host_key_last_verified"), hostKeyLastVerified: text("host_key_last_verified"),
hostKeyChangedCount: integer("host_key_changed_count").default(0), hostKeyChangedCount: integer("host_key_changed_count").default(0),
// Stable identity used to match this row across two independently-seeded
// databases (the embedded backend and a connected remote server) during
// sync -- local autoincrement ids collide across instances.
syncId: text("sync_id").unique(),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
@@ -357,6 +368,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
usageCount: integer("usage_count").notNull().default(0), usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"), lastUsed: text("last_used"),
syncId: text("sync_id").unique(),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
@@ -391,6 +403,7 @@ export const snippets = sqliteTable("snippets", {
description: text("description"), description: text("description"),
folder: text("folder"), folder: text("folder"),
order: integer("order").notNull().default(0), order: integer("order").notNull().default(0),
syncId: text("sync_id").unique(),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
@@ -408,6 +421,7 @@ export const snippetFolders = sqliteTable("snippet_folders", {
name: text("name").notNull(), name: text("name").notNull(),
color: text("color"), color: text("color"),
icon: text("icon"), icon: text("icon"),
syncId: text("sync_id").unique(),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
@@ -468,6 +482,7 @@ export const sshFolders = sqliteTable("ssh_folders", {
credentialId: integer("credential_id").references(() => sshCredentials.id, { credentialId: integer("credential_id").references(() => sshCredentials.id, {
onDelete: "set null", onDelete: "set null",
}), }),
syncId: text("sync_id").unique(),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
@@ -786,6 +801,7 @@ export const vaultProfiles = sqliteTable("vault_profiles", {
keyType: text("key_type"), keyType: text("key_type"),
// When true the profile is visible/usable by all users on the server // When true the profile is visible/usable by all users on the server
shared: integer("shared", { mode: "boolean" }).notNull().default(false), shared: integer("shared", { mode: "boolean" }).notNull().default(false),
syncId: text("sync_id").unique(),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
@@ -942,9 +958,13 @@ export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
label: text("label").notNull(), label: text("label").notNull(),
url: text("url").notNull(), url: text("url").notNull(),
order: integer("order").notNull().default(0), order: integer("order").notNull().default(0),
syncId: text("sync_id").unique(),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
}); });
// --- termix-id begin --- // --- termix-id begin ---
@@ -1130,6 +1150,7 @@ export const homepageItems = sqliteTable("homepage_items", {
title: text("title"), title: text("title"),
config: text("config").notNull().default("{}"), config: text("config").notNull().default("{}"),
folderId: integer("folder_id"), folderId: integer("folder_id"),
syncId: text("sync_id").unique(),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
@@ -1151,3 +1172,20 @@ export const homepageLayouts = sqliteTable("homepage_layouts", {
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
}); });
// --- homepage end --- // --- homepage end ---
// --- sync begin ---
// Records a delete for a synced entity type so the other side of a sync
// pair (embedded desktop backend <-> connected remote server) learns about
// the deletion instead of re-creating the row on its next pull.
export const syncTombstones = sqliteTable("sync_tombstones", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
syncId: text("sync_id").notNull(),
deletedAt: text("deleted_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
// --- sync end ---
@@ -1,4 +1,5 @@
import { and, desc, eq, sql } from "drizzle-orm"; import { and, desc, eq, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { sshCredentials, sshCredentialUsage } from "../db/schema.js"; import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js"; import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js"; import { DataCrypto } from "../../utils/data-crypto.js";
@@ -18,7 +19,7 @@ export class CredentialRepository {
async create(credential: NewCredentialRecord): Promise<CredentialRecord> { async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
const rows = await this.context.drizzle const rows = await this.context.drizzle
.insert(sshCredentials) .insert(sshCredentials)
.values(credential) .values({ syncId: randomUUID(), ...credential })
.returning(); .returning();
await this.afterWrite(); await this.afterWrite();
return rows[0]; return rows[0];
@@ -30,7 +31,11 @@ export class CredentialRepository {
): Promise<CredentialRecord> { ): Promise<CredentialRecord> {
const userDataKey = DataCrypto.validateUserAccess(userId); const userDataKey = DataCrypto.validateUserAccess(userId);
const tempId = credential.id ?? Date.now(); const tempId = credential.id ?? Date.now();
const dataWithTempId = { ...credential, id: tempId }; const dataWithTempId = {
syncId: randomUUID(),
...credential,
id: tempId,
};
const encryptedCredential = this.encryptCredentialRecordForWrite( const encryptedCredential = this.encryptCredentialRecordForWrite(
dataWithTempId, dataWithTempId,
userId, userId,
@@ -203,7 +208,10 @@ export class CredentialRepository {
return this.decryptOne(rows[0] ?? null, userId); return this.decryptOne(rows[0] ?? null, userId);
} }
async deleteForUser(userId: string, credentialId: number): Promise<boolean> { async deleteForUser(
userId: string,
credentialId: number,
): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle const rows = await this.context.drizzle
.delete(sshCredentials) .delete(sshCredentials)
.where( .where(
@@ -212,10 +220,10 @@ export class CredentialRepository {
eq(sshCredentials.userId, userId), eq(sshCredentials.userId, userId),
), ),
) )
.returning({ id: sshCredentials.id }); .returning({ syncId: sshCredentials.syncId });
await this.afterWrite(); await this.afterWrite();
return rows.length > 0; return rows[0] ?? null;
} }
async deleteByUserId(userId: string): Promise<number> { async deleteByUserId(userId: string): Promise<number> {
@@ -1,4 +1,5 @@
import { and, asc, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
import { randomUUID } from "crypto";
import { dashboardServiceLinks } from "../db/schema.js"; import { dashboardServiceLinks } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js"; import type { DatabaseContext } from "./database-context.js";
@@ -40,11 +41,13 @@ export class DashboardServiceLinkRepository {
const [created] = await this.context.drizzle const [created] = await this.context.drizzle
.insert(dashboardServiceLinks) .insert(dashboardServiceLinks)
.values({ .values({
syncId: randomUUID(),
userId, userId,
label: input.label, label: input.label,
url: input.url, url: input.url,
order: nextOrder, order: nextOrder,
createdAt, createdAt,
updatedAt: createdAt,
}) })
.returning(); .returning();
await this.afterWrite(); await this.afterWrite();
@@ -76,7 +79,7 @@ export class DashboardServiceLinkRepository {
): Promise<DashboardServiceLinkRecord | null> { ): Promise<DashboardServiceLinkRecord | null> {
const [updated] = await this.context.drizzle const [updated] = await this.context.drizzle
.update(dashboardServiceLinks) .update(dashboardServiceLinks)
.set(updates) .set({ ...updates, updatedAt: new Date().toISOString() })
.where( .where(
and( and(
eq(dashboardServiceLinks.id, id), eq(dashboardServiceLinks.id, id),
@@ -92,7 +95,10 @@ export class DashboardServiceLinkRepository {
return updated ?? null; return updated ?? null;
} }
async deleteForUser(userId: string, id: number): Promise<boolean> { async deleteForUser(
userId: string,
id: number,
): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle const rows = await this.context.drizzle
.delete(dashboardServiceLinks) .delete(dashboardServiceLinks)
.where( .where(
@@ -101,13 +107,11 @@ export class DashboardServiceLinkRepository {
eq(dashboardServiceLinks.userId, userId), eq(dashboardServiceLinks.userId, userId),
), ),
) )
.returning({ id: dashboardServiceLinks.id }); .returning({ syncId: dashboardServiceLinks.syncId });
if (rows.length > 0) { if (rows.length === 0) return null;
await this.afterWrite(); await this.afterWrite();
} return rows[0];
return rows.length > 0;
} }
async deleteByUserId(userId: string): Promise<number> { async deleteByUserId(userId: string): Promise<number> {
@@ -32,6 +32,7 @@ import { SettingsRepository } from "./settings-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js"; import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SnippetRepository } from "./snippet-repository.js"; import { SnippetRepository } from "./snippet-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js"; import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
import { SsoProviderRepository } from "./sso-provider-repository.js"; import { SsoProviderRepository } from "./sso-provider-repository.js";
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js"; import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
import { TermixIdentityRepository } from "./termix-identity-repository.js"; import { TermixIdentityRepository } from "./termix-identity-repository.js";
@@ -126,6 +127,13 @@ export function createCurrentDashboardServiceLinkRepository(): DashboardServiceL
); );
} }
export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository {
return new SyncTombstoneRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("sync_tombstone_repository_write"),
);
}
export function createCurrentDismissedAlertRepository(): DismissedAlertRepository { export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
return new DismissedAlertRepository( return new DismissedAlertRepository(
createCurrentRepositoryContext(), createCurrentRepositoryContext(),
@@ -1,4 +1,5 @@
import { and, asc, eq } from "drizzle-orm"; import { and, asc, eq } from "drizzle-orm";
import { randomUUID } from "crypto";
import { homepageItems } from "../db/schema.js"; import { homepageItems } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js"; import type { DatabaseContext } from "./database-context.js";
@@ -37,6 +38,7 @@ export class HomepageItemRepository {
const [created] = await this.context.drizzle const [created] = await this.context.drizzle
.insert(homepageItems) .insert(homepageItems)
.values({ .values({
syncId: randomUUID(),
userId, userId,
typeId: input.typeId, typeId: input.typeId,
title: input.title, title: input.title,
@@ -82,17 +84,18 @@ export class HomepageItemRepository {
return updated ?? null; return updated ?? null;
} }
async deleteForUser(userId: string, id: number): Promise<boolean> { async deleteForUser(
userId: string,
id: number,
): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle const rows = await this.context.drizzle
.delete(homepageItems) .delete(homepageItems)
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId))) .where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
.returning({ id: homepageItems.id }); .returning({ syncId: homepageItems.syncId });
if (rows.length > 0) { if (rows.length === 0) return null;
await this.afterWrite(); await this.afterWrite();
} return rows[0];
return rows.length > 0;
} }
async deleteByUserId(userId: string): Promise<number> { async deleteByUserId(userId: string): Promise<number> {
@@ -1,4 +1,5 @@
import { and, eq, like, or, sql } from "drizzle-orm"; import { and, eq, like, or, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import type { SQLiteColumn } from "drizzle-orm/sqlite-core"; import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
import { hosts, sshCredentials, sshFolders } from "../db/schema.js"; import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js"; import type { DatabaseContext } from "./database-context.js";
@@ -96,6 +97,7 @@ export class HostFolderRepository {
const [created] = await this.context.drizzle const [created] = await this.context.drizzle
.insert(sshFolders) .insert(sshFolders)
.values({ .values({
syncId: randomUUID(),
userId, userId,
name, name,
color, color,
@@ -126,7 +128,7 @@ export class HostFolderRepository {
async deleteHostsAndFolderRecords( async deleteHostsAndFolderRecords(
userId: string, userId: string,
folderName: string, folderName: string,
): Promise<void> { ): Promise<{ hostSyncIds: string[]; folderSyncIds: string[] }> {
const folderMatch = (col: SQLiteColumn) => const folderMatch = (col: SQLiteColumn) =>
or(eq(col, folderName), like(col, `${folderName} / %`)); or(eq(col, folderName), like(col, `${folderName} / %`));
@@ -137,11 +139,21 @@ export class HostFolderRepository {
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder))); .where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
} }
await this.context.drizzle const deletedFolders = await this.context.drizzle
.delete(sshFolders) .delete(sshFolders)
.where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name))); .where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)))
.returning({ syncId: sshFolders.syncId });
await this.afterWrite(); await this.afterWrite();
return {
hostSyncIds: hostsToDelete
.map((h) => h.syncId)
.filter((id): id is string => !!id),
folderSyncIds: deletedFolders
.map((f) => f.syncId)
.filter((id): id is string => !!id),
};
} }
async deleteByUserId(userId: string): Promise<number> { async deleteByUserId(userId: string): Promise<number> {
@@ -1,4 +1,5 @@
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { randomUUID } from "crypto";
import { hostAccess, hosts } from "../db/schema.js"; import { hostAccess, hosts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js"; import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js"; import { DataCrypto } from "../../utils/data-crypto.js";
@@ -22,7 +23,7 @@ export class HostRepository {
async create(host: NewHostRecord): Promise<HostRecord> { async create(host: NewHostRecord): Promise<HostRecord> {
const rows = await this.context.drizzle const rows = await this.context.drizzle
.insert(hosts) .insert(hosts)
.values(host) .values({ syncId: randomUUID(), ...host })
.returning(); .returning();
await this.afterWrite(); await this.afterWrite();
return rows[0]; return rows[0];
@@ -34,7 +35,11 @@ export class HostRepository {
): Promise<HostRecord> { ): Promise<HostRecord> {
const userDataKey = DataCrypto.validateUserAccess(userId); const userDataKey = DataCrypto.validateUserAccess(userId);
const tempId = host.id ?? Date.now(); const tempId = host.id ?? Date.now();
const dataWithTempId = { ...host, id: tempId }; const dataWithTempId = {
syncId: randomUUID(),
...host,
id: tempId,
};
const encryptedHost = DataCrypto.encryptRecord( const encryptedHost = DataCrypto.encryptRecord(
"ssh_data", "ssh_data",
dataWithTempId, dataWithTempId,
@@ -221,16 +226,19 @@ export class HostRepository {
return rows.length; return rows.length;
} }
async deleteForUser(userId: string, hostId: number): Promise<boolean> { async deleteForUser(
userId: string,
hostId: number,
): Promise<{ syncId: string | null } | null> {
await this.deleteAccessForHost(hostId); await this.deleteAccessForHost(hostId);
const rows = await this.context.drizzle const rows = await this.context.drizzle
.delete(hosts) .delete(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId))) .where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.returning({ id: hosts.id }); .returning({ syncId: hosts.syncId });
await this.afterWrite(); await this.afterWrite();
return rows.length > 0; return rows[0] ?? null;
} }
async deleteByUserId(userId: string): Promise<number> { async deleteByUserId(userId: string): Promise<number> {
@@ -1,4 +1,5 @@
import { and, asc, eq, sql } from "drizzle-orm"; import { and, asc, eq, sql } from "drizzle-orm";
import { randomUUID } from "crypto";
import { snippetFolders, snippets } from "../db/schema.js"; import { snippetFolders, snippets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js"; import type { DatabaseContext } from "./database-context.js";
@@ -151,6 +152,7 @@ export class SnippetRepository {
const rows = await this.context.drizzle const rows = await this.context.drizzle
.insert(snippets) .insert(snippets)
.values({ .values({
syncId: randomUUID(),
userId, userId,
name: input.name.trim(), name: input.name.trim(),
content: input.content.trim(), content: input.content.trim(),
@@ -343,6 +345,7 @@ export class SnippetRepository {
const maxOrder = await this.maxOrderForFolder(userId, folderVal); const maxOrder = await this.maxOrderForFolder(userId, folderVal);
await this.context.drizzle.insert(snippets).values({ await this.context.drizzle.insert(snippets).values({
syncId: randomUUID(),
userId, userId,
name: snippet.name.trim(), name: snippet.name.trim(),
content: snippet.content.trim(), content: snippet.content.trim(),
@@ -377,6 +380,7 @@ export class SnippetRepository {
const rows = await this.context.drizzle const rows = await this.context.drizzle
.insert(snippetFolders) .insert(snippetFolders)
.values({ .values({
syncId: randomUUID(),
userId, userId,
name: name.trim(), name: name.trim(),
color: color?.trim() || null, color: color?.trim() || null,
@@ -452,19 +456,24 @@ export class SnippetRepository {
return { status: "renamed" }; return { status: "renamed" };
} }
async deleteFolder(userId: string, name: string): Promise<void> { async deleteFolder(
userId: string,
name: string,
): Promise<{ syncId: string | null } | null> {
await this.context.drizzle await this.context.drizzle
.update(snippets) .update(snippets)
.set({ folder: null }) .set({ folder: null })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name))); .where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
await this.context.drizzle const rows = await this.context.drizzle
.delete(snippetFolders) .delete(snippetFolders)
.where( .where(
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
); )
.returning({ syncId: snippetFolders.syncId });
await this.afterWrite(); await this.afterWrite();
return rows[0] ?? null;
} }
private async findFolderByName( private async findFolderByName(
@@ -0,0 +1,70 @@
import { and, eq, gt } from "drizzle-orm";
import { syncTombstones } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect;
export type SyncEntityType =
| "hosts"
| "sshCredentials"
| "sshFolders"
| "snippets"
| "snippetFolders"
| "vaultProfiles"
| "dashboardServiceLinks"
| "homepageItems";
export class SyncTombstoneRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async record(
userId: string,
entityType: SyncEntityType,
syncId: string,
): Promise<void> {
if (!syncId) return;
await this.context.drizzle.insert(syncTombstones).values({
userId,
entityType,
syncId,
});
await this.afterWrite();
}
async recordMany(
userId: string,
entityType: SyncEntityType,
syncIds: string[],
): Promise<void> {
const rows = syncIds.filter(Boolean);
if (rows.length === 0) return;
await this.context.drizzle
.insert(syncTombstones)
.values(rows.map((syncId) => ({ userId, entityType, syncId })));
await this.afterWrite();
}
async listSince(
userId: string,
entityType: SyncEntityType,
since: string | null,
): Promise<SyncTombstoneRecord[]> {
const conditions = [
eq(syncTombstones.userId, userId),
eq(syncTombstones.entityType, entityType),
];
if (since) conditions.push(gt(syncTombstones.deletedAt, since));
return this.context.drizzle
.select()
.from(syncTombstones)
.where(and(...conditions));
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,4 +1,5 @@
import { desc, eq, or } from "drizzle-orm"; import { desc, eq, or } from "drizzle-orm";
import { randomUUID } from "crypto";
import { vaultProfiles } from "../db/schema.js"; import { vaultProfiles } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js"; import type { DatabaseContext } from "./database-context.js";
@@ -47,6 +48,7 @@ export class VaultProfileRepository {
const [created] = await this.context.drizzle const [created] = await this.context.drizzle
.insert(vaultProfiles) .insert(vaultProfiles)
.values({ .values({
syncId: randomUUID(),
userId: input.userId, userId: input.userId,
name: input.name, name: input.name,
description: input.description, description: input.description,
@@ -98,17 +100,15 @@ export class VaultProfileRepository {
return updated ?? null; return updated ?? null;
} }
async deleteById(id: number): Promise<boolean> { async deleteById(id: number): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle const rows = await this.context.drizzle
.delete(vaultProfiles) .delete(vaultProfiles)
.where(eq(vaultProfiles.id, id)) .where(eq(vaultProfiles.id, id))
.returning({ id: vaultProfiles.id }); .returning({ syncId: vaultProfiles.syncId });
if (rows.length > 0) { if (rows.length === 0) return null;
await this.afterWrite(); await this.afterWrite();
} return rows[0];
return rows.length > 0;
} }
async deleteByUserId(userId: string): Promise<number> { async deleteByUserId(userId: string): Promise<number> {
@@ -460,9 +460,9 @@ export function registerAcmeSSLRoutes(
!certificate.includes("BEGIN CERTIFICATE") || !certificate.includes("BEGIN CERTIFICATE") ||
!privateKey.includes("PRIVATE KEY") !privateKey.includes("PRIVATE KEY")
) { ) {
return res return res.status(400).json({
.status(400) error: "A valid PEM certificate and private key are required",
.json({ error: "A valid PEM certificate and private key are required" }); });
} }
await fs.mkdir(SSL_DIR, { recursive: true }); await fs.mkdir(SSL_DIR, { recursive: true });
@@ -485,7 +485,8 @@ export function registerAcmeSSLRoutes(
); );
} catch { } catch {
return res.status(400).json({ return res.status(400).json({
error: "The provided certificate or private key is not valid PEM data", error:
"The provided certificate or private key is not valid PEM data",
}); });
} }
@@ -12,6 +12,7 @@ import {
createCurrentHostResolutionRepository, createCurrentHostResolutionRepository,
createCurrentHostRepository, createCurrentHostRepository,
createCurrentUserRepository, createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js"; } from "../repositories/factory.js";
const router = express.Router(); const router = express.Router();
@@ -642,6 +643,13 @@ router.delete(
userId, userId,
credentialId, credentialId,
); );
if (credentialToDelete.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"sshCredentials",
credentialToDelete.syncId,
);
}
// Shares stay in place; re-snapshot so recipients fall back to whatever // Shares stay in place; re-snapshot so recipients fall back to whatever
// auth the host still has (or lose the stale credential copy). // auth the host still has (or lose the stale credential copy).
@@ -4,7 +4,10 @@ import { dashboardLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { isNonEmptyString } from "./host-normalizers.js"; import { isNonEmptyString } from "./host-normalizers.js";
import express from "express"; import express from "express";
import { createCurrentDashboardServiceLinkRepository } from "../repositories/factory.js"; import {
createCurrentDashboardServiceLinkRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
export const dashboardServiceLinksRouter = express.Router(); export const dashboardServiceLinksRouter = express.Router();
@@ -152,10 +155,18 @@ dashboardServiceLinksRouter.delete(
return res.status(404).json({ error: "Not found" }); return res.status(404).json({ error: "Not found" });
} }
const deleted =
await createCurrentDashboardServiceLinkRepository().deleteForUser( await createCurrentDashboardServiceLinkRepository().deleteForUser(
userId, userId,
id, id,
); );
if (deleted?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"dashboardServiceLinks",
deleted.syncId,
);
}
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted"); DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
res.json({ message: "Service link deleted" }); res.json({ message: "Service link deleted" });
@@ -1,7 +1,10 @@
import type { AuthenticatedRequest } from "../../../types/index.js"; import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import { homepageLogger } from "../../utils/logger.js"; import { homepageLogger } from "../../utils/logger.js";
import { createCurrentHomepageItemRepository } from "../repositories/factory.js"; import {
createCurrentHomepageItemRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import express from "express"; import express from "express";
export const homepageItemsRouter = express.Router(); export const homepageItemsRouter = express.Router();
@@ -184,7 +187,14 @@ homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => {
return res.status(404).json({ error: "Not found" }); return res.status(404).json({ error: "Not found" });
} }
await itemRepository.deleteForUser(userId, id); const deleted = await itemRepository.deleteForUser(userId, id);
if (deleted?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"homepageItems",
deleted.syncId,
);
}
res.json({ message: "Homepage item deleted" }); res.json({ message: "Homepage item deleted" });
} catch (err) { } catch (err) {
homepageLogger.error("Failed to delete homepage item", err); homepageLogger.error("Failed to delete homepage item", err);
@@ -11,6 +11,7 @@ import {
createCurrentSshCredentialUsageRepository, createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository, createCurrentSessionRecordingRepository,
createCurrentTransferRecentRepository, createCurrentTransferRecentRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js"; } from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js"; import { isNonEmptyString } from "./host-normalizers.js";
@@ -318,10 +319,18 @@ export function registerHostFolderRoutes(
); );
} }
const { hostSyncIds, folderSyncIds } =
await hostFolderRepository.deleteHostsAndFolderRecords( await hostFolderRepository.deleteHostsAndFolderRecords(
userId, userId,
folderName, folderName,
); );
const tombstoneRepository = createCurrentSyncTombstoneRepository();
await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds);
await tombstoneRepository.recordMany(
userId,
"sshFolders",
folderSyncIds,
);
try { try {
const axios = (await import("axios")).default; const axios = (await import("axios")).default;
+18
View File
@@ -26,6 +26,7 @@ import {
createCurrentHostResolutionRepository, createCurrentHostResolutionRepository,
createCurrentHostRepository, createCurrentHostRepository,
createCurrentUserRepository, createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js"; } from "../repositories/factory.js";
import { import {
isNonEmptyString, isNonEmptyString,
@@ -201,6 +202,7 @@ router.post(
socks5Username, socks5Username,
socks5Password, socks5Password,
socks5ProxyChain, socks5ProxyChain,
connectionOrigin,
portKnockSequence, portKnockSequence,
overrideCredentialUsername, overrideCredentialUsername,
macAddress, macAddress,
@@ -331,6 +333,10 @@ router.post(
socks5ProxyChain: socks5ProxyChain socks5ProxyChain: socks5ProxyChain
? JSON.stringify(socks5ProxyChain) ? JSON.stringify(socks5ProxyChain)
: null, : null,
connectionOrigin:
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
macAddress: macAddress || null, macAddress: macAddress || null,
wolBroadcastAddress: wolBroadcastAddress || null, wolBroadcastAddress: wolBroadcastAddress || null,
portKnockSequence: portKnockSequence portKnockSequence: portKnockSequence
@@ -843,6 +849,7 @@ router.put(
socks5Username, socks5Username,
socks5Password, socks5Password,
socks5ProxyChain, socks5ProxyChain,
connectionOrigin,
portKnockSequence, portKnockSequence,
overrideCredentialUsername, overrideCredentialUsername,
macAddress, macAddress,
@@ -970,6 +977,10 @@ router.put(
socks5ProxyChain: socks5ProxyChain socks5ProxyChain: socks5ProxyChain
? JSON.stringify(socks5ProxyChain) ? JSON.stringify(socks5ProxyChain)
: null, : null,
connectionOrigin:
connectionOrigin === "local" || connectionOrigin === "remote"
? connectionOrigin
: null,
macAddress: macAddress || null, macAddress: macAddress || null,
wolBroadcastAddress: wolBroadcastAddress || null, wolBroadcastAddress: wolBroadcastAddress || null,
portKnockSequence: portKnockSequence portKnockSequence: portKnockSequence
@@ -2054,6 +2065,13 @@ router.delete(
); );
await createCurrentHostRepository().deleteForUser(userId, numericHostId); await createCurrentHostRepository().deleteForUser(userId, numericHostId);
if (hostToDelete.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"hosts",
hostToDelete.syncId,
);
}
databaseLogger.success("SSH host deleted", { databaseLogger.success("SSH host deleted", {
operation: "host_delete_success", operation: "host_delete_success",
+20 -1
View File
@@ -12,6 +12,7 @@ import {
createCurrentRoleRepository, createCurrentRoleRepository,
createCurrentSnippetRepository, createCurrentSnippetRepository,
createCurrentUserRepository, createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js"; } from "../repositories/factory.js";
const router = express.Router(); const router = express.Router();
@@ -400,7 +401,17 @@ router.delete(
try { try {
const folderName = decodeURIComponent(name); const folderName = decodeURIComponent(name);
await createCurrentSnippetRepository().deleteFolder(userId, folderName); const deletedFolder = await createCurrentSnippetRepository().deleteFolder(
userId,
folderName,
);
if (deletedFolder?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"snippetFolders",
deletedFolder.syncId,
);
}
authLogger.success( authLogger.success(
`Snippet folder deleted: ${folderName} by user ${userId}`, `Snippet folder deleted: ${folderName} by user ${userId}`,
@@ -1241,6 +1252,14 @@ router.delete(
return res.status(404).json({ error: "Snippet not found" }); return res.status(404).json({ error: "Snippet not found" });
} }
if (existing.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"snippets",
existing.syncId,
);
}
databaseLogger.info("Command snippet deleted", { databaseLogger.info("Command snippet deleted", {
operation: "snippet_delete", operation: "snippet_delete",
userId, userId,
+415
View File
@@ -0,0 +1,415 @@
import type { Request, Response } from "express";
import express from "express";
import { and, eq, gt } from "drizzle-orm";
import {
hosts,
sshCredentials,
sshFolders,
snippets,
snippetFolders,
vaultProfiles,
dashboardServiceLinks,
homepageItems,
} from "../db/schema.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { databaseLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import {
createCurrentRepositoryContext,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
// Encrypted tables need DataCrypto to translate between the wire payload
// (plaintext) and the stored row (encrypted). Everything else is stored
// and synced as-is.
const ENCRYPTED_ENTITY_TABLES: Partial<Record<SyncEntityType, string>> = {
hosts: "ssh_data",
sshCredentials: "ssh_credentials",
};
interface EntityConfig {
table:
| typeof hosts
| typeof sshCredentials
| typeof sshFolders
| typeof snippets
| typeof snippetFolders
| typeof vaultProfiles
| typeof dashboardServiceLinks
| typeof homepageItems;
// Fields that only make sense on the device that created the row, or
// that are managed elsewhere and must never be overwritten by a sync
// payload from the other side.
readOnlyFields: string[];
}
const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
hosts: {
table: hosts,
readOnlyFields: ["connectionOrigin"],
},
sshCredentials: { table: sshCredentials, readOnlyFields: [] },
sshFolders: { table: sshFolders, readOnlyFields: [] },
snippets: { table: snippets, readOnlyFields: [] },
snippetFolders: { table: snippetFolders, readOnlyFields: [] },
vaultProfiles: { table: vaultProfiles, readOnlyFields: [] },
dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] },
homepageItems: { table: homepageItems, readOnlyFields: [] },
};
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
export function isValidEntityType(value: unknown): value is SyncEntityType {
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
}
function requireUserDataKey(userId: string): Buffer {
return DataCrypto.validateUserAccess(userId);
}
function decryptIfNeeded(
entityType: SyncEntityType,
row: Record<string, unknown>,
userId: string,
): Record<string, unknown> {
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
if (!tableName) return row;
const userDataKey = DataCrypto.getUserDataKey(userId);
if (!userDataKey) return row;
return DataCrypto.decryptRecord(
tableName,
row,
userId,
userDataKey,
) as Record<string, unknown>;
}
function encryptIfNeeded(
entityType: SyncEntityType,
row: Record<string, unknown>,
userId: string,
): Record<string, unknown> {
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
if (!tableName) return row;
const userDataKey = requireUserDataKey(userId);
return DataCrypto.encryptRecord(
tableName,
row,
userId,
userDataKey,
) as Record<string, unknown>;
}
export function stripWritePayload(
entityType: SyncEntityType,
payload: Record<string, unknown>,
): Record<string, unknown> {
const { readOnlyFields } = ENTITY_CONFIG[entityType];
const clean = { ...payload };
delete clean.id;
delete clean.userId;
delete clean.syncId;
for (const field of readOnlyFields) delete clean[field];
return clean;
}
/**
* @openapi
* /sync/{entityType}:
* get:
* summary: Pull synced rows for an entity type
* description: Returns rows owned by the authenticated user whose updatedAt is newer than `since` (or all rows if omitted). Used by the desktop app's remote sync engine to reconcile the embedded backend against a connected remote server.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* - in: query
* name: since
* schema:
* type: string
* responses:
* 200:
* description: Rows updated since the given timestamp.
* 400:
* description: Unknown entity type.
* 500:
* description: Failed to fetch rows.
*/
router.get(
"/:entityType",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const since =
typeof req.query.since === "string" && req.query.since
? req.query.since
: null;
try {
const { table } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
const conditions = [eq(table.userId, userId)];
if (since && "updatedAt" in table) {
conditions.push(gt((table as typeof hosts).updatedAt, since));
}
const rows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(and(...conditions));
const decrypted = rows.map((row) =>
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
);
res.json({ rows: decrypted });
} catch (err) {
databaseLogger.error(`Failed to pull sync rows for ${entityType}`, err, {
operation: "sync_pull",
entityType,
userId,
});
res.status(500).json({ error: "Failed to fetch rows" });
}
},
);
/**
* @openapi
* /sync/{entityType}:
* post:
* summary: Upsert a synced row by syncId
* description: Creates or updates a row by its syncId. Used by the desktop app's remote sync engine to push local-only or newer rows to the other side of a sync pair.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Row upserted.
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to upsert row.
*/
router.post(
"/:entityType",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const payload = req.body?.row;
const syncId = payload?.syncId;
if (!payload || typeof syncId !== "string" || !syncId) {
return res.status(400).json({ error: "Missing row.syncId" });
}
try {
const { table } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
const existingRows = await context.drizzle
.select()
.from(table as typeof hosts)
.where(
and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
)
.limit(1);
const existing = existingRows[0] as Record<string, unknown> | undefined;
const writePayload = stripWritePayload(entityType, payload);
const encryptedPayload = encryptIfNeeded(
entityType,
writePayload,
userId,
);
let resultRow: Record<string, unknown>;
if (existing) {
const updatedRows = await context.drizzle
.update(table as typeof hosts)
.set(encryptedPayload)
.where(
and(
eq((table as typeof hosts).id, existing.id as number),
eq(table.userId, userId),
),
)
.returning();
resultRow = updatedRows[0] as Record<string, unknown>;
} else {
const insertedRows = await context.drizzle
.insert(table as typeof hosts)
.values({
...encryptedPayload,
userId,
syncId,
} as typeof hosts.$inferInsert)
.returning();
resultRow = insertedRows[0] as Record<string, unknown>;
}
await DatabaseSaveTrigger.forceSave("sync_upsert");
res.json({
row: decryptIfNeeded(entityType, resultRow, userId),
created: !existing,
});
} catch (err) {
databaseLogger.error(`Failed to upsert sync row for ${entityType}`, err, {
operation: "sync_upsert",
entityType,
userId,
});
res.status(500).json({ error: "Failed to upsert row" });
}
},
);
/**
* @openapi
* /sync/{entityType}/tombstones:
* get:
* summary: Pull deletion tombstones for an entity type
* description: Returns tombstones recorded since `since` so the other side of a sync pair can apply the same deletions.
* tags:
* - Sync
* parameters:
* - in: path
* name: entityType
* required: true
* schema:
* type: string
* - in: query
* name: since
* schema:
* type: string
* responses:
* 200:
* description: Tombstones recorded since the given timestamp.
* 400:
* description: Unknown entity type.
* 500:
* description: Failed to fetch tombstones.
*/
router.get(
"/:entityType/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.params.entityType;
if (!isValidEntityType(entityType)) {
return res.status(400).json({ error: "Unknown entity type" });
}
const since =
typeof req.query.since === "string" && req.query.since
? req.query.since
: null;
try {
const tombstones = await createCurrentSyncTombstoneRepository().listSince(
userId,
entityType,
since,
);
res.json({ tombstones });
} catch (err) {
databaseLogger.error(
`Failed to fetch sync tombstones for ${entityType}`,
err,
{ operation: "sync_tombstones_pull", entityType, userId },
);
res.status(500).json({ error: "Failed to fetch tombstones" });
}
},
);
/**
* @openapi
* /sync/tombstones:
* post:
* summary: Report a deletion from the other side of a sync pair
* description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent.
* tags:
* - Sync
* responses:
* 200:
* description: Deletion applied (or row already absent).
* 400:
* description: Unknown entity type or missing syncId.
* 500:
* description: Failed to apply deletion.
*/
router.post(
"/tombstones",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const entityType = req.body?.entityType;
const syncId = req.body?.syncId;
if (
!isValidEntityType(entityType) ||
typeof syncId !== "string" ||
!syncId
) {
return res.status(400).json({ error: "Missing entityType or syncId" });
}
try {
const { table } = ENTITY_CONFIG[entityType];
const context = createCurrentRepositoryContext();
await context.drizzle
.delete(table as typeof hosts)
.where(
and(
eq((table as typeof hosts).syncId, syncId),
eq(table.userId, userId),
),
);
await createCurrentSyncTombstoneRepository().record(
userId,
entityType,
syncId,
);
await DatabaseSaveTrigger.forceSave("sync_tombstone_applied");
res.json({ success: true });
} catch (err) {
databaseLogger.error("Failed to apply sync tombstone", err, {
operation: "sync_tombstone_apply",
entityType,
userId,
});
res.status(500).json({ error: "Failed to apply deletion" });
}
},
);
export default router;
+96
View File
@@ -1880,6 +1880,102 @@ router.get("/setup-required", async (req, res) => {
} }
}); });
function isLoopbackRequest(req: Request): boolean {
const ip = req.ip || req.socket?.remoteAddress || "";
return (
ip === "127.0.0.1" ||
ip === "::1" ||
ip === "::ffff:127.0.0.1" ||
ip.endsWith(":127.0.0.1")
);
}
/**
* @openapi
* /users/internal/auto-session:
* post:
* summary: Mint a session for the auto-provisioned local desktop user
* description: Used by the Electron desktop app to skip the login form when running standalone with a single auto-provisioned local user. Only available over loopback and only when exactly one user exists -- a real multi-user or synced install never satisfies this, so no further secret is required.
* tags:
* - Users
* responses:
* 200:
* description: Session created.
* 403:
* description: Forbidden, or more than one user exists.
* 500:
* description: Failed to create session.
*/
router.post("/internal/auto-session", async (req, res) => {
try {
if (!isLoopbackRequest(req)) {
authLogger.warn(
"Rejected non-loopback attempt to access auto-session endpoint",
{ source: req.ip },
);
return res.status(403).json({ error: "Forbidden" });
}
const userRepository = createCurrentUserRepository();
const allUsers = await userRepository.listAll();
if (allUsers.length !== 1) {
return res.status(403).json({
error: "Auto-session is only available for a single local user",
});
}
const userRecord = allUsers[0];
// If the caller already holds a still-valid session for this same
// user (e.g. a duplicate call racing the first one, such as React
// StrictMode's double-invoke of effects in dev), reuse it instead of
// minting a fresh one. Minting unconditionally here would set a new
// `jwt` cookie on every call; since the auth middleware prefers the
// cookie over the Authorization header, a second mint silently
// invalidates whatever token the app already started using.
const existingToken =
(req as Request & { cookies?: Record<string, string> }).cookies?.jwt ||
(req.headers["authorization"]?.startsWith("Bearer ")
? req.headers["authorization"].slice("Bearer ".length)
: undefined);
if (existingToken) {
const existingPayload = await authManager.verifyJWTToken(existingToken);
if (existingPayload?.userId === userRecord.id) {
return res.json({
success: true,
is_admin: !!userRecord.isAdmin,
username: userRecord.username,
token: existingToken,
});
}
}
const token = await authManager.generateJWTToken(userRecord.id, {
deviceType: "desktop",
deviceInfo: "Termix Desktop (local)",
rememberMe: true,
});
const response = {
success: true,
is_admin: !!userRecord.isAdmin,
username: userRecord.username,
token,
};
return res
.cookie(
"jwt",
token,
authManager.getSecureCookieOptions(req, 30 * 24 * 60 * 60 * 1000),
)
.json(response);
} catch (err) {
authLogger.error("Failed to create auto-session", err);
res.status(500).json({ error: "Failed to create auto-session" });
}
});
/** /**
* @openapi * @openapi
* /users/count: * /users/count:
+9 -1
View File
@@ -3,6 +3,7 @@ import type { Request, Response } from "express";
import { import {
createCurrentVaultProfileRepository, createCurrentVaultProfileRepository,
createCurrentUserRepository, createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js"; } from "../repositories/factory.js";
import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js"; import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js";
import type { AuthenticatedRequest } from "../../../types/index.js"; import type { AuthenticatedRequest } from "../../../types/index.js";
@@ -421,7 +422,14 @@ router.delete(
.status(403) .status(403)
.json({ error: "Only the owner can delete this profile" }); .json({ error: "Only the owner can delete this profile" });
} }
await repository.deleteById(id); const deleted = await repository.deleteById(id);
if (deleted?.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"vaultProfiles",
deleted.syncId,
);
}
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
authLogger.error("Failed to delete vault profile", err); authLogger.error("Failed to delete vault profile", err);
+58
View File
@@ -15,6 +15,60 @@ import {
setGlobalLogLevel, setGlobalLogLevel,
} from "./utils/logger.js"; } from "./utils/logger.js";
async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { createCurrentUserRepository, createCurrentRoleRepository } =
await import("./database/repositories/factory.js");
const { AuthManager } = await import("./utils/auth-manager.js");
const crypto = await import("crypto");
const userRepository = createCurrentUserRepository();
const existingCount = await userRepository.countAll();
if (existingCount > 0) return;
const id = crypto.randomUUID();
const { isFirstUser } = await userRepository.createFirstLocalUser({
id,
username: "local",
passwordHash: "",
isOidc: false,
clientId: "",
clientSecret: "",
issuerUrl: "",
authorizationUrl: "",
tokenUrl: "",
identifierPath: "",
namePath: "",
scopes: "openid email profile",
totpSecret: null,
totpEnabled: false,
totpBackupCodes: null,
});
try {
await createCurrentRoleRepository().assignRoleNameToUser({
userId: id,
roleName: isFirstUser ? "admin" : "user",
grantedBy: id,
});
} catch (roleError) {
systemLogger.error(
"Failed to assign default role to auto-provisioned local user",
roleError,
{ operation: "desktop_auto_provision_role" },
);
}
await AuthManager.getInstance().registerUser(
id,
crypto.randomBytes(32).toString("hex"),
);
systemLogger.success("Auto-provisioned local desktop user", {
operation: "desktop_auto_provision",
userId: id,
});
}
(async () => { (async () => {
const initStartTime = Date.now(); const initStartTime = Date.now();
try { try {
@@ -107,6 +161,10 @@ import {
await import("./utils/crypto-migration/shared-host-secrets-migration.js"); await import("./utils/crypto-migration/shared-host-secrets-migration.js");
await runSharedHostSecretsMigration(); await runSharedHostSecretsMigration();
if (process.env.ELECTRON_EMBEDDED === "true") {
await provisionLocalDesktopUserIfNeeded();
}
import("./utils/opkssh-binary-manager.js").then( import("./utils/opkssh-binary-manager.js").then(
({ OPKSSHBinaryManager }) => { ({ OPKSSHBinaryManager }) => {
OPKSSHBinaryManager.ensureBinary().catch((error) => { OPKSSHBinaryManager.ensureBinary().catch((error) => {
@@ -32,7 +32,9 @@ describe("DashboardServiceLinkRepository", () => {
label TEXT NOT NULL, label TEXT NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0, "order" INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
INSERT INTO users (id, username, password_hash) INSERT INTO users (id, username, password_hash)
@@ -99,8 +101,10 @@ describe("DashboardServiceLinkRepository", () => {
); );
expect(writeCount).toBe(2); expect(writeCount).toBe(2);
expect(await repo.deleteForUser("user-2", link.id)).toBe(false); expect(await repo.deleteForUser("user-2", link.id)).toBeNull();
expect(await repo.deleteForUser("user-1", link.id)).toBe(true); expect(await repo.deleteForUser("user-1", link.id)).toEqual({
syncId: expect.any(String),
});
expect(writeCount).toBe(3); expect(writeCount).toBe(3);
}); });
@@ -31,6 +31,7 @@ describe("HomepageItemRepository", () => {
title TEXT, title TEXT,
config TEXT NOT NULL DEFAULT '{}', config TEXT NOT NULL DEFAULT '{}',
folder_id INTEGER, folder_id INTEGER,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -107,8 +108,10 @@ describe("HomepageItemRepository", () => {
).toBeNull(); ).toBeNull();
expect(writeCount).toBe(2); expect(writeCount).toBe(2);
expect(await repo.deleteForUser("user-2", item.id)).toBe(false); expect(await repo.deleteForUser("user-2", item.id)).toBeNull();
expect(await repo.deleteForUser("user-1", item.id)).toBe(true); expect(await repo.deleteForUser("user-1", item.id)).toEqual({
syncId: expect.any(String),
});
expect(writeCount).toBe(3); expect(writeCount).toBe(3);
}); });
@@ -55,6 +55,7 @@ describe("HostRepository and CredentialRepository", () => {
cert_public_key TEXT, cert_public_key TEXT,
usage_count INTEGER NOT NULL DEFAULT 0, usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT, last_used TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -151,6 +152,8 @@ describe("HostRepository and CredentialRepository", () => {
host_key_first_seen TEXT, host_key_first_seen TEXT,
host_key_last_verified TEXT, host_key_last_verified TEXT,
host_key_changed_count INTEGER DEFAULT 0, host_key_changed_count INTEGER DEFAULT 0,
connection_origin TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
@@ -226,9 +229,9 @@ describe("HostRepository and CredentialRepository", () => {
expect( expect(
await repo.credentials.findByIdForUser("user-2", created.id), await repo.credentials.findByIdForUser("user-2", created.id),
).toBeNull(); ).toBeNull();
expect(await repo.credentials.deleteForUser("user-1", created.id)).toBe( expect(await repo.credentials.deleteForUser("user-1", created.id)).toEqual({
true, syncId: expect.any(String),
); });
expect( expect(
await repo.credentials.findByIdForUser("user-1", created.id), await repo.credentials.findByIdForUser("user-1", created.id),
).toBeNull(); ).toBeNull();
@@ -449,7 +452,9 @@ describe("HostRepository and CredentialRepository", () => {
expect(updated?.name).toBe("web-1-renamed"); expect(updated?.name).toBe("web-1-renamed");
expect(await repo.hosts.findByIdForUser("user-2", host.id)).toBeNull(); expect(await repo.hosts.findByIdForUser("user-2", host.id)).toBeNull();
expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true); expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
syncId: expect.any(String),
});
expect(await repo.hosts.findById(host.id)).toBeNull(); expect(await repo.hosts.findById(host.id)).toBeNull();
}); });
@@ -687,6 +692,8 @@ describe("HostRepository and CredentialRepository", () => {
.run(host.id, "user-2", "user-1"); .run(host.id, "user-2", "user-1");
expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1); expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1);
expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true); expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
syncId: expect.any(String),
});
}); });
}); });
@@ -35,6 +35,7 @@ describe("HostFolderRepository", () => {
name TEXT NOT NULL, name TEXT NOT NULL,
folder TEXT, folder TEXT,
auth_type TEXT NOT NULL, auth_type TEXT NOT NULL,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -130,6 +131,8 @@ describe("HostFolderRepository", () => {
host_key_first_seen TEXT, host_key_first_seen TEXT,
host_key_last_verified TEXT, host_key_last_verified TEXT,
host_key_changed_count INTEGER DEFAULT 0, host_key_changed_count INTEGER DEFAULT 0,
connection_origin TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -141,6 +144,7 @@ describe("HostFolderRepository", () => {
color TEXT, color TEXT,
icon TEXT, icon TEXT,
credential_id INTEGER, credential_id INTEGER,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -125,6 +125,8 @@ describe("HostResolutionRepository", () => {
host_key_first_seen TEXT, host_key_first_seen TEXT,
host_key_last_verified TEXT, host_key_last_verified TEXT,
host_key_changed_count INTEGER DEFAULT 0, host_key_changed_count INTEGER DEFAULT 0,
connection_origin TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -148,6 +150,7 @@ describe("HostResolutionRepository", () => {
cert_public_key TEXT, cert_public_key TEXT,
usage_count INTEGER NOT NULL DEFAULT 0, usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT, last_used TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -173,6 +176,7 @@ describe("HostResolutionRepository", () => {
color TEXT, color TEXT,
icon TEXT, icon TEXT,
credential_id INTEGER, credential_id INTEGER,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -29,6 +29,7 @@ describe("SnippetRepository", () => {
description TEXT, description TEXT,
folder TEXT, folder TEXT,
"order" INTEGER NOT NULL DEFAULT 0, "order" INTEGER NOT NULL DEFAULT 0,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
host_filter TEXT host_filter TEXT
@@ -40,6 +41,7 @@ describe("SnippetRepository", () => {
name TEXT NOT NULL, name TEXT NOT NULL,
color TEXT, color TEXT,
icon TEXT, icon TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "./test-support.js";
import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js";
describe("SyncTombstoneRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<SyncTombstoneRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
context.sqlite?.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE sync_tombstones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
sync_id TEXT NOT NULL,
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
`);
return new SyncTombstoneRepository(context, onWrite);
}
it("records a tombstone and lists it back for the owning user", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.record("user-1", "hosts", "sync-abc");
expect(writeCount).toBe(1);
const rows = await repo.listSince("user-1", "hosts", null);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
userId: "user-1",
entityType: "hosts",
syncId: "sync-abc",
});
});
it("does not record a tombstone for an empty syncId", async () => {
const repo = await createRepository();
await repo.record("user-1", "hosts", "");
const rows = await repo.listSince("user-1", "hosts", null);
expect(rows).toHaveLength(0);
});
it("recordMany writes multiple tombstones and filters out falsy ids", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.recordMany("user-1", "hosts", ["a", "", "b", "c"]);
expect(writeCount).toBe(1);
const rows = await repo.listSince("user-1", "hosts", null);
expect(rows.map((r) => r.syncId).sort()).toEqual(["a", "b", "c"]);
});
it("recordMany is a no-op when given no syncIds", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.recordMany("user-1", "hosts", []);
expect(writeCount).toBe(0);
});
it("scopes listSince by userId and entityType", async () => {
const repo = await createRepository();
await repo.record("user-1", "hosts", "sync-1");
await repo.record("user-1", "snippets", "sync-2");
await repo.record("user-2", "hosts", "sync-3");
const rows = await repo.listSince("user-1", "hosts", null);
expect(rows).toHaveLength(1);
expect(rows[0].syncId).toBe("sync-1");
});
it("filters listSince by the since timestamp", async () => {
const adapterLocal = new TestSqliteDatabase();
adapter = adapterLocal;
const context = await adapterLocal.connect();
context.sqlite?.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE sync_tombstones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
sync_id TEXT NOT NULL,
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash');
INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at)
VALUES
('user-1', 'hosts', 'old', '2026-01-01T00:00:00.000Z'),
('user-1', 'hosts', 'new', '2026-06-01T00:00:00.000Z');
`);
const repo = new SyncTombstoneRepository(context);
const rows = await repo.listSince(
"user-1",
"hosts",
"2026-03-01T00:00:00.000Z",
);
expect(rows).toHaveLength(1);
expect(rows[0].syncId).toBe("new");
});
});
@@ -113,6 +113,8 @@ describe("UserDataExportRepository", () => {
host_key_first_seen TEXT, host_key_first_seen TEXT,
host_key_last_verified TEXT, host_key_last_verified TEXT,
host_key_changed_count INTEGER DEFAULT 0, host_key_changed_count INTEGER DEFAULT 0,
connection_origin TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -136,6 +138,7 @@ describe("UserDataExportRepository", () => {
cert_public_key TEXT, cert_public_key TEXT,
usage_count INTEGER NOT NULL DEFAULT 0, usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT, last_used TEXT,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -40,6 +40,7 @@ describe("VaultProfileRepository", () => {
valid_principals TEXT, valid_principals TEXT,
key_type TEXT, key_type TEXT,
shared INTEGER NOT NULL DEFAULT 0, shared INTEGER NOT NULL DEFAULT 0,
sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -119,8 +120,8 @@ describe("VaultProfileRepository", () => {
}); });
expect(await repo.updateById(999, { name: "missing" })).toBeNull(); expect(await repo.updateById(999, { name: "missing" })).toBeNull();
expect(await repo.deleteById(1)).toBe(true); expect(await repo.deleteById(1)).toEqual({ syncId: null });
expect(await repo.deleteById(1)).toBe(false); expect(await repo.deleteById(1)).toBeNull();
expect(await repo.findById(1)).toBeNull(); expect(await repo.findById(1)).toBeNull();
expect(writeCount).toBe(2); expect(writeCount).toBe(2);
}); });
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
isValidEntityType,
stripWritePayload,
} from "../../../database/routes/sync.js";
describe("isValidEntityType", () => {
it("accepts every whitelisted sync entity type", () => {
for (const type of [
"hosts",
"sshCredentials",
"sshFolders",
"snippets",
"snippetFolders",
"vaultProfiles",
"dashboardServiceLinks",
"homepageItems",
]) {
expect(isValidEntityType(type)).toBe(true);
}
});
it("rejects unknown or non-string entity types", () => {
expect(isValidEntityType("hostAccess")).toBe(false);
expect(isValidEntityType("")).toBe(false);
expect(isValidEntityType(undefined)).toBe(false);
expect(isValidEntityType(42)).toBe(false);
});
});
describe("stripWritePayload", () => {
it("strips id, userId, and syncId from every entity type", () => {
const payload = {
id: 1,
userId: "user-1",
syncId: "abc",
name: "prod-db",
};
expect(stripWritePayload("sshFolders", payload)).toEqual({
name: "prod-db",
});
});
it("also strips desktop-only fields flagged read-only for hosts", () => {
const payload = {
id: 1,
userId: "user-1",
syncId: "abc",
name: "web",
connectionOrigin: "remote",
};
expect(stripWritePayload("hosts", payload)).toEqual({ name: "web" });
});
it("does not mutate the original payload object", () => {
const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
stripWritePayload("snippets", payload);
expect(payload).toEqual({
id: 1,
userId: "user-1",
syncId: "abc",
name: "x",
});
});
});
+10 -18
View File
@@ -11,9 +11,8 @@ function makeChain(resolveValue: unknown) {
for (const m of methods) { for (const m of methods) {
chain[m] = vi.fn(() => chain); chain[m] = vi.fn(() => chain);
} }
(chain as unknown as Promise<unknown>).then = ( (chain as unknown as Promise<unknown>).then = (cb: (v: unknown) => unknown) =>
cb: (v: unknown) => unknown, Promise.resolve(resolveValue).then(cb);
) => Promise.resolve(resolveValue).then(cb);
return chain; return chain;
} }
@@ -72,9 +71,7 @@ describe("analytics", () => {
it("getOrCreateInstanceId returns the existing id without generating one", async () => { it("getOrCreateInstanceId returns the existing id without generating one", async () => {
mockGet.mockResolvedValue("existing-id"); mockGet.mockResolvedValue("existing-id");
const { getOrCreateInstanceId } = await import( const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
"../../utils/analytics.js"
);
const id = await getOrCreateInstanceId(); const id = await getOrCreateInstanceId();
@@ -84,9 +81,7 @@ describe("analytics", () => {
it("getOrCreateInstanceId generates and persists a new id when absent", async () => { it("getOrCreateInstanceId generates and persists a new id when absent", async () => {
mockGet.mockResolvedValue(null); mockGet.mockResolvedValue(null);
const { getOrCreateInstanceId } = await import( const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
"../../utils/analytics.js"
);
const id = await getOrCreateInstanceId(); const id = await getOrCreateInstanceId();
@@ -96,9 +91,8 @@ describe("analytics", () => {
it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => { it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => {
delete process.env.POSTHOG_API_KEY; delete process.env.POSTHOG_API_KEY;
const { collectAndSendHeartbeat } = await import( const { collectAndSendHeartbeat } =
"../../utils/analytics.js" await import("../../utils/analytics.js");
);
await collectAndSendHeartbeat(); await collectAndSendHeartbeat();
@@ -108,9 +102,8 @@ describe("analytics", () => {
it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => { it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => {
process.env.POSTHOG_API_KEY = "phc_test"; process.env.POSTHOG_API_KEY = "phc_test";
mockGetBoolean.mockResolvedValue(false); mockGetBoolean.mockResolvedValue(false);
const { collectAndSendHeartbeat } = await import( const { collectAndSendHeartbeat } =
"../../utils/analytics.js" await import("../../utils/analytics.js");
);
await collectAndSendHeartbeat(); await collectAndSendHeartbeat();
@@ -122,9 +115,8 @@ describe("analytics", () => {
mockGetBoolean.mockResolvedValue(true); mockGetBoolean.mockResolvedValue(true);
mockGet.mockResolvedValue("instance-123"); mockGet.mockResolvedValue("instance-123");
mockPost.mockResolvedValue({}); mockPost.mockResolvedValue({});
const { collectAndSendHeartbeat } = await import( const { collectAndSendHeartbeat } =
"../../utils/analytics.js" await import("../../utils/analytics.js");
);
await collectAndSendHeartbeat(); await collectAndSendHeartbeat();
+7 -5
View File
@@ -25,7 +25,10 @@ const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000; const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
export async function isAnalyticsEnabled(): Promise<boolean> { export async function isAnalyticsEnabled(): Promise<boolean> {
return createCurrentSettingsRepository().getBoolean("analytics_enabled", true); return createCurrentSettingsRepository().getBoolean(
"analytics_enabled",
true,
);
} }
export async function getOrCreateInstanceId(): Promise<string> { export async function getOrCreateInstanceId(): Promise<string> {
@@ -120,10 +123,9 @@ export async function collectAndSendHeartbeat(): Promise<void> {
export function startAnalyticsHeartbeat(): void { export function startAnalyticsHeartbeat(): void {
if (!process.env.POSTHOG_API_KEY) { if (!process.env.POSTHOG_API_KEY) {
analyticsLogger.info( analyticsLogger.info("Analytics disabled: POSTHOG_API_KEY not set", {
"Analytics disabled: POSTHOG_API_KEY not set", operation: "analytics_disabled_no_key",
{ operation: "analytics_disabled_no_key" }, });
);
return; return;
} }
+35 -12
View File
@@ -180,6 +180,7 @@ function App() {
stored?.loggedIn ? "verifying" : "idle-auth", stored?.loggedIn ? "verifying" : "idle-auth",
); );
const [authUsername, setAuthUsername] = useState(stored?.username ?? ""); const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
const [verifyRetryCount, setVerifyRetryCount] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Track whether fading-in came from a fresh login (vs. session verification on page load). // Track whether fading-in came from a fresh login (vs. session verification on page load).
// When session-verified, Auth must not mount during the transition — it would trigger // When session-verified, Auth must not mount during the transition — it would trigger
@@ -219,11 +220,36 @@ function App() {
setPhase("fading-in"); setPhase("fading-in");
timerRef.current = setTimeout(() => setPhase("idle-app"), 450); timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
}) })
.catch(() => { .catch((err: unknown) => {
// Only treat a genuine auth rejection (401/403) as "not logged in".
// Anything else (network hiccup, backend still starting up, a
// transient 5xx) is not proof the session is invalid -- clearing
// stored auth here would drop the user back to Auth.tsx, which in
// Electron immediately mints a brand-new auto-session, silently
// swapping out the JWT/cookie from under any still-in-flight
// requests and causing spurious "Session expired" toasts.
const status =
(err as { status?: number; response?: { status?: number } })
?.status ??
(err as { response?: { status?: number } })?.response?.status;
if (status === 401 || status === 403) {
clearStoredAuth(); clearStoredAuth();
setPhase("idle-auth"); setPhase("idle-auth");
return;
}
// Transient failure: retry shortly rather than logging out. Cap
// retries so a genuinely broken backend still surfaces the login
// screen eventually instead of spinning forever.
if (verifyRetryCount >= 5) {
clearStoredAuth();
setPhase("idle-auth");
return;
}
timerRef.current = setTimeout(() => {
setVerifyRetryCount((c) => c + 1);
}, 3000);
}); });
}, [phase]); }, [phase, verifyRetryCount]);
function handleLogin(u: string) { function handleLogin(u: string) {
setAuthUsername(u); setAuthUsername(u);
@@ -232,6 +258,12 @@ function App() {
timerRef.current = setTimeout(() => setPhase("idle-app"), 450); timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
if (isElectron()) { if (isElectron()) {
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {}); window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
const localJwt = localStorage.getItem("jwt");
if (localJwt) {
window.electronAPI
?.invoke?.("notify-local-login", localJwt)
.catch(() => {});
}
} }
} }
@@ -244,11 +276,6 @@ function App() {
}, 450); }, 450);
} }
function handleChangeServer() {
localStorage.setItem("termix_show_server_config", "true");
handleLogout();
}
const showApp = const showApp =
phase === "idle-app" || phase === "fading-in" || phase === "fading-out"; phase === "idle-app" || phase === "fading-in" || phase === "fading-out";
const showAuth = const showAuth =
@@ -294,11 +321,7 @@ function App() {
}} }}
> >
<Suspense fallback={null}> <Suspense fallback={null}>
<AppShell <AppShell username={authUsername} onLogout={handleLogout} />
username={authUsername}
onLogout={handleLogout}
onChangeServer={handleChangeServer}
/>
</Suspense> </Suspense>
</div> </div>
)} )}
+9 -1
View File
@@ -64,6 +64,15 @@ export interface ElectronAPI {
started: number; started: number;
errors: string[]; errors: string[];
}>; }>;
onRemoteSyncStatusChanged?: (
callback: (status: {
connected: boolean;
syncing: boolean;
lastSyncedAt: string | null;
lastError: string | null;
needsReauth: boolean;
}) => void,
) => () => void;
clearSessionCookies: () => Promise<void>; clearSessionCookies: () => Promise<void>;
getSessionCookie: ( getSessionCookie: (
name: string, name: string,
@@ -157,7 +166,6 @@ declare global {
interface Window { interface Window {
electronAPI: ElectronAPI; electronAPI: ElectronAPI;
IS_ELECTRON: boolean; IS_ELECTRON: boolean;
configuredServerUrl?: string | null;
electronClipboard?: { electronClipboard?: {
writeText(text: string): Promise<boolean>; writeText(text: string): Promise<boolean>;
readText(): Promise<string>; readText(): Promise<string>;
+1
View File
@@ -71,6 +71,7 @@ export type Host = {
useSocks5?: boolean; useSocks5?: boolean;
socks5Host?: string; socks5Host?: string;
socks5Port?: number; socks5Port?: number;
connectionOrigin?: "local" | "remote" | null;
socks5Username?: string; socks5Username?: string;
socks5Password?: string; socks5Password?: string;
socks5ProxyChain?: { socks5ProxyChain?: {
+25 -6
View File
@@ -126,10 +126,12 @@ import {
getActiveSessions, getActiveSessions,
getUserPreferences, getUserPreferences,
dismissDonationModal, dismissDonationModal,
isElectron,
type UserPreferences, type UserPreferences,
type OpenTabRecord, type OpenTabRecord,
} from "@/main-axios"; } from "@/main-axios";
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx"; import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
import { dbHealthMonitor } from "@/lib/db-health-monitor"; import { dbHealthMonitor } from "@/lib/db-health-monitor";
import type { SSHHostWithStatus } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios";
import { ServerStatusProvider } from "@/lib/ServerStatusContext"; import { ServerStatusProvider } from "@/lib/ServerStatusContext";
@@ -193,11 +195,9 @@ export { tabIcon, renderTabContent } from "@/shell/tabUtils";
export function AppShell({ export function AppShell({
username, username,
onLogout, onLogout,
onChangeServer,
}: { }: {
username: string; username: string;
onLogout: () => void; onLogout: () => void;
onChangeServer?: () => void;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { setTheme } = useTheme(); const { setTheme } = useTheme();
@@ -238,6 +238,13 @@ export function AppShell({
const [hostsLoading, setHostsLoading] = useState(true); const [hostsLoading, setHostsLoading] = useState(true);
const [allHosts, setAllHosts] = useState<Host[]>([]); const [allHosts, setAllHosts] = useState<Host[]>([]);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
// Remote sync is not yet configurable (added in a later phase), so this
// is always false for now -- admin/user-management UI stays hidden until
// the desktop app is connected to a remote Termix server, since a
// standalone local install has exactly one implicit user and nothing to
// administer.
const [isRemoteSyncConnected] = useState(false);
const showMultiUserUI = isAdmin && (!isElectron() || isRemoteSyncConnected);
const [userId, setUserId] = useState<string | null>(null); const [userId, setUserId] = useState<string | null>(null);
const [showDonationModal, setShowDonationModal] = useState(false); const [showDonationModal, setShowDonationModal] = useState(false);
const [backgroundTabRecords, setBackgroundTabRecords] = useState< const [backgroundTabRecords, setBackgroundTabRecords] = useState<
@@ -1806,7 +1813,6 @@ export function AppShell({
<UserProfilePanel <UserProfilePanel
username={username} username={username}
onLogout={onLogout} onLogout={onLogout}
onChangeServer={onChangeServer}
userPrefs={userPrefs} userPrefs={userPrefs}
onPrefsChange={(updates) => onPrefsChange={(updates) =>
setUserPrefs((current) => ({ ...current, ...updates })) setUserPrefs((current) => ({ ...current, ...updates }))
@@ -1815,7 +1821,7 @@ export function AppShell({
</div> </div>
)} )}
{railView === "admin-settings" && isAdmin && ( {railView === "admin-settings" && showMultiUserUI && (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto"> <div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<AdminSettingsPanel <AdminSettingsPanel
onEditingChange={setSidebarEditing} onEditingChange={setSidebarEditing}
@@ -1870,14 +1876,26 @@ export function AppShell({
return ( return (
<ServerStatusProvider isAuthenticated={!!username}> <ServerStatusProvider isAuthenticated={!!username}>
<div className="flex w-screen bg-background" style={{ height: "100dvh" }}> <div
className="flex flex-col w-screen bg-background"
style={{ height: "100dvh" }}
>
{isElectron() && (
<RemoteSyncBanner
onReconnect={() => {
setRailView("user-profile");
if (!sidebarOpen) setSidebarOpen(true);
}}
/>
)}
<div className="flex flex-1 min-h-0">
{/* Skinny icon rail — desktop only, hidden on mobile */} {/* Skinny icon rail — desktop only, hidden on mobile */}
<AppRail <AppRail
railView={railView} railView={railView}
sidebarOpen={sidebarOpen} sidebarOpen={sidebarOpen}
splitMode={splitMode} splitMode={splitMode}
username={username} username={username}
isAdmin={isAdmin} isAdmin={showMultiUserUI}
onRailClick={handleRailClick} onRailClick={handleRailClick}
onOpenTab={openSingletonTab} onOpenTab={openSingletonTab}
onLogout={onLogout} onLogout={onLogout}
@@ -2047,6 +2065,7 @@ export function AppShell({
/> />
</div> </div>
</div> </div>
</div>
{commandPaletteOpen && ( {commandPaletteOpen && (
<Suspense fallback={null}> <Suspense fallback={null}>
+9
View File
@@ -1,6 +1,15 @@
import { handleApiError, statsApi } from "@/main-axios"; import { handleApiError, statsApi } from "@/main-axios";
import type { HostMetricsLayout } from "@/types/host-metrics"; import type { HostMetricsLayout } from "@/types/host-metrics";
// Every function below is keyed by a host's numeric database id, and the
// receiving backend must own that host in its own database -- a synced
// host has a different numeric id on each side (only its syncId matches
// across them). These calls always target the embedded local backend; see
// getAllServerStatuses in host-metrics-status-api.ts for the one metrics
// call that IS safely merged across local + remote (a process-local,
// in-memory aggregate keyed by whichever host ids that process happens to
// know about, not a per-host lookup).
export interface MetricsHistoryRow { export interface MetricsHistoryRow {
ts: string; ts: string;
cpu_percent: number | null; cpu_percent: number | null;
+44 -2
View File
@@ -1,8 +1,31 @@
import axios, { type AxiosRequestConfig } from "axios"; import axios, { type AxiosRequestConfig } from "axios";
import { handleApiError, statsApi } from "@/main-axios"; import {
handleApiError,
statsApi,
getRemoteStatsApi,
isElectron,
} from "@/main-axios";
import type { ServerMetrics, ServerStatus } from "@/main-axios"; import type { ServerMetrics, ServerStatus } from "@/main-axios";
import { getCachedServerStatuses } from "@/lib/hosts-request-cache"; import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
// Metrics collection/viewer registration below (startMetricsPolling,
// registerMetricsViewer, etc.) is NOT origin-routed: the backend that
// receives the call must own the target host by numeric database id, and a
// synced host has a different numeric id in each database (only its
// syncId matches across them). Only the aggregate status read is merged
// across local + remote, same as tunnel status.
async function isRemoteSyncConnected(): Promise<boolean> {
if (!isElectron()) return false;
try {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
return !!config?.serverUrl;
} catch {
return false;
}
}
type ApiConnectionLog = { type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error"; type: "info" | "success" | "warning" | "error";
stage: string; stage: string;
@@ -76,6 +99,7 @@ export async function getAllServerStatuses(): Promise<
> { > {
return getCachedServerStatuses(async () => { return getCachedServerStatuses(async () => {
let lastError: unknown = null; let lastError: unknown = null;
let localStatuses: Record<number, ServerStatus> = {};
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) { for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i]; const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
@@ -89,7 +113,9 @@ export async function getAllServerStatuses(): Promise<
// blips don't look like real outages. // blips don't look like real outages.
__silentRetry: !isFinalAttempt, __silentRetry: !isFinalAttempt,
} as AxiosRequestConfig & { __silentRetry?: boolean }); } as AxiosRequestConfig & { __silentRetry?: boolean });
return response.data || {}; localStatuses = response.data || {};
lastError = null;
break;
} catch (error) { } catch (error) {
lastError = error; lastError = error;
if (!isTransientStatusError(error)) { if (!isTransientStatusError(error)) {
@@ -102,8 +128,24 @@ export async function getAllServerStatuses(): Promise<
} }
} }
if (lastError) {
handleApiError(lastError, "fetch server statuses"); handleApiError(lastError, "fetch server statuses");
return {}; return {};
}
if (await isRemoteSyncConnected()) {
try {
const remoteResult = await getRemoteStatsApi().get("/status", {
timeout: 8000,
__silentRetry: true,
} as AxiosRequestConfig & { __silentRetry?: boolean });
return { ...localStatuses, ...(remoteResult.data || {}) };
} catch {
// remote unreachable this tick -- fall back to local-only statuses
}
}
return localStatuses;
}); });
} }
+7 -6
View File
@@ -1,7 +1,7 @@
import axios from "axios"; import axios from "axios";
import { getBasePath } from "@/lib/base-path"; import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { authApi, getServerConfig, handleApiError } from "@/main-axios"; import { authApi, handleApiError } from "@/main-axios";
export interface ResolvedShareLink { export interface ResolvedShareLink {
protocol: "ssh" | "rdp" | "vnc" | "telnet"; protocol: "ssh" | "rdp" | "vnc" | "telnet";
@@ -30,17 +30,18 @@ const isDev = (): boolean =>
window.location.port === ""); window.location.port === "");
// Guests have no session/JWT, so this deliberately builds a bare base URL // Guests have no session/JWT, so this deliberately builds a bare base URL
// rather than going through main-axios's authenticated instances. // rather than going through main-axios's authenticated instances. The
// desktop app always runs its embedded local backend as the source of
// truth, so a share link opened there always resolves against it --
// joining a session hosted on someone else's remote server isn't
// supported from the desktop app today.
async function resolveApiBaseUrl(): Promise<string> { async function resolveApiBaseUrl(): Promise<string> {
if (isDev()) { if (isDev()) {
const protocol = window.location.protocol === "https:" ? "https" : "http"; const protocol = window.location.protocol === "https:" ? "https" : "http";
return `${protocol}://localhost:30001`; return `${protocol}://localhost:30001`;
} }
if (isElectron()) { if (isElectron()) {
const serverConfig = await getServerConfig(); return "http://127.0.0.1:30001";
const configuredUrl = serverConfig?.serverUrl;
if (configuredUrl) return configuredUrl.replace(/\/$/, "");
return "http://localhost:30001";
} }
return getBasePath(); return getBasePath();
} }
+95 -84
View File
@@ -1,5 +1,13 @@
import axios from "axios"; import axios from "axios";
import { authApi, fileManagerApi, handleApiError } from "@/main-axios"; import {
authApi,
fileManagerApi,
handleApiError,
getFileManagerApiForSession,
setSessionOrigin,
clearSessionOrigin,
} from "@/main-axios";
import { resolveConnectionOrigin } from "@/lib/connection-origin";
import { fileLogger } from "@/lib/frontend-logger"; import { fileLogger } from "@/lib/frontend-logger";
import type { SSHHost } from "@/types/index"; import type { SSHHost } from "@/types/index";
@@ -72,7 +80,7 @@ export async function connectSSH(
}, },
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.post( const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/connect", "/ssh/connect",
{ sessionId, ...config }, { sessionId, ...config },
{ timeout: 120000 }, { timeout: 120000 },
@@ -121,12 +129,15 @@ export async function disconnectSSH(
sessionId: string, sessionId: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.post("/ssh/disconnect", { const response = await getFileManagerApiForSession(sessionId).post(
sessionId, "/ssh/disconnect",
}); { sessionId },
);
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "disconnect SSH"); handleApiError(error, "disconnect SSH");
} finally {
clearSessionOrigin(sessionId);
} }
} }
@@ -135,10 +146,10 @@ export async function verifySSHTOTP(
totpCode: string, totpCode: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.post("/ssh/connect-totp", { const response = await getFileManagerApiForSession(sessionId).post(
sessionId, "/ssh/connect-totp",
totpCode, { sessionId, totpCode },
}); );
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "verify SSH TOTP"); handleApiError(error, "verify SSH TOTP");
@@ -149,9 +160,10 @@ export async function verifySSHWarpgate(
sessionId: string, sessionId: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.post("/ssh/connect-warpgate", { const response = await getFileManagerApiForSession(sessionId).post(
sessionId, "/ssh/connect-warpgate",
}); { sessionId },
);
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "verify SSH Warpgate"); handleApiError(error, "verify SSH Warpgate");
@@ -239,9 +251,10 @@ export async function getSSHStatus(
sessionId: string, sessionId: string,
): Promise<{ connected: boolean }> { ): Promise<{ connected: boolean }> {
try { try {
const response = await fileManagerApi.get("/ssh/status", { const response = await getFileManagerApiForSession(sessionId).get(
params: { sessionId }, "/ssh/status",
}); { params: { sessionId } },
);
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "get SSH status"); handleApiError(error, "get SSH status");
@@ -252,9 +265,10 @@ export async function keepSSHAlive(
sessionId: string, sessionId: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.post("/ssh/keepalive", { const response = await getFileManagerApiForSession(sessionId).post(
sessionId, "/ssh/keepalive",
}); { sessionId },
);
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "SSH keepalive"); handleApiError(error, "SSH keepalive");
@@ -266,9 +280,10 @@ export async function listSSHFiles(
path: string, path: string,
): Promise<{ files: unknown[]; path: string }> { ): Promise<{ files: unknown[]; path: string }> {
try { try {
const response = await fileManagerApi.get("/ssh/listFiles", { const response = await getFileManagerApiForSession(sessionId).get(
params: { sessionId, path }, "/ssh/listFiles",
}); { params: { sessionId, path } },
);
return response.data || { files: [], path }; return response.data || { files: [], path };
} catch (error) { } catch (error) {
handleApiError(error, "list SSH files"); handleApiError(error, "list SSH files");
@@ -281,9 +296,10 @@ export async function identifySSHSymlink(
path: string, path: string,
): Promise<{ path: string; target: string; type: "directory" | "file" }> { ): Promise<{ path: string; target: string; type: "directory" | "file" }> {
try { try {
const response = await fileManagerApi.get("/ssh/identifySymlink", { const response = await getFileManagerApiForSession(sessionId).get(
params: { sessionId, path }, "/ssh/identifySymlink",
}); { params: { sessionId, path } },
);
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "identify SSH symlink"); handleApiError(error, "identify SSH symlink");
@@ -295,9 +311,10 @@ export async function resolveSSHPath(
path: string, path: string,
): Promise<string> { ): Promise<string> {
try { try {
const response = await fileManagerApi.get("/ssh/resolvePath", { const response = await getFileManagerApiForSession(sessionId).get(
params: { sessionId, path }, "/ssh/resolvePath",
}); { params: { sessionId, path } },
);
return response.data?.resolvedPath || path; return response.data?.resolvedPath || path;
} catch { } catch {
return path; return path;
@@ -313,9 +330,10 @@ export async function readSSHFile(
encoding?: "base64" | "utf8"; encoding?: "base64" | "utf8";
}> { }> {
try { try {
const response = await fileManagerApi.get("/ssh/readFile", { const response = await getFileManagerApiForSession(sessionId).get(
params: { sessionId, path }, "/ssh/readFile",
}); { params: { sessionId, path } },
);
return response.data; return response.data;
} catch (error: unknown) { } catch (error: unknown) {
if (error.response?.status === 404) { if (error.response?.status === 404) {
@@ -340,13 +358,10 @@ export async function writeSSHFile(
userId?: string, userId?: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.post("/ssh/writeFile", { const response = await getFileManagerApiForSession(sessionId).post(
sessionId, "/ssh/writeFile",
path, { sessionId, path, content, hostId, userId },
content, );
hostId,
userId,
});
if ( if (
response.data && response.data &&
@@ -410,7 +425,7 @@ export async function uploadSSHFile(
form.append("totalSize", String(file.size)); form.append("totalSize", String(file.size));
form.append("chunk", chunkBlob, fileName); form.append("chunk", chunkBlob, fileName);
const response = await fileManagerApi.postForm( const response = await getFileManagerApiForSession(sessionId).postForm(
"/ssh/uploadFileChunk", "/ssh/uploadFileChunk",
form, form,
{ timeout: 0 }, { timeout: 0 },
@@ -444,7 +459,7 @@ export async function uploadSSHFile(
if (userId !== undefined) form.append("userId", userId); if (userId !== undefined) form.append("userId", userId);
form.append("file", file, fileName); form.append("file", file, fileName);
const response = await fileManagerApi.postForm( const response = await getFileManagerApiForSession(sessionId).postForm(
"/ssh/uploadFileStream", "/ssh/uploadFileStream",
form, form,
{ {
@@ -464,7 +479,7 @@ export async function downloadSSHFile(
userId?: string, userId?: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.post( const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/downloadFile", "/ssh/downloadFile",
{ {
sessionId, sessionId,
@@ -484,7 +499,7 @@ export async function downloadSSHFileStream(
sessionId: string, sessionId: string,
filePath: string, filePath: string,
): Promise<void> { ): Promise<void> {
const response = await fileManagerApi.post( const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/downloadFileStream", "/ssh/downloadFileStream",
{ sessionId, path: filePath }, { sessionId, path: filePath },
{ responseType: "blob", timeout: 0 }, { responseType: "blob", timeout: 0 },
@@ -503,14 +518,10 @@ export async function createSSHFile(
userId?: string, userId?: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.post("/ssh/createFile", { const response = await getFileManagerApiForSession(sessionId).post(
sessionId, "/ssh/createFile",
path, { sessionId, path, fileName, content, hostId, userId },
fileName, );
content,
hostId,
userId,
});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "create SSH file"); handleApiError(error, "create SSH file");
@@ -525,13 +536,10 @@ export async function createSSHFolder(
userId?: string, userId?: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.post("/ssh/createFolder", { const response = await getFileManagerApiForSession(sessionId).post(
sessionId, "/ssh/createFolder",
path, { sessionId, path, folderName, hostId, userId },
folderName, );
hostId,
userId,
});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "create SSH folder"); handleApiError(error, "create SSH folder");
@@ -546,7 +554,9 @@ export async function deleteSSHItem(
userId?: string, userId?: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.delete("/ssh/deleteItem", { const response = await getFileManagerApiForSession(sessionId).delete(
"/ssh/deleteItem",
{
data: { data: {
sessionId, sessionId,
path, path,
@@ -554,7 +564,8 @@ export async function deleteSSHItem(
hostId, hostId,
userId, userId,
}, },
}); },
);
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "delete SSH item"); handleApiError(error, "delete SSH item");
@@ -566,7 +577,7 @@ export async function setSudoPassword(
password: string, password: string,
): Promise<void> { ): Promise<void> {
try { try {
await fileManagerApi.post("/sudo-password", { await getFileManagerApiForSession(sessionId).post("/sudo-password", {
sessionId, sessionId,
password, password,
}); });
@@ -583,7 +594,7 @@ export async function copySSHItem(
userId?: string, userId?: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.post( const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/copyItem", "/ssh/copyItem",
{ {
sessionId, sessionId,
@@ -611,13 +622,10 @@ export async function renameSSHItem(
userId?: string, userId?: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.put("/ssh/renameItem", { const response = await getFileManagerApiForSession(sessionId).put(
sessionId, "/ssh/renameItem",
oldPath, { sessionId, oldPath, newName, hostId, userId },
newName, );
hostId,
userId,
});
return response.data; return response.data;
} catch (error) { } catch (error) {
handleApiError(error, "rename SSH item"); handleApiError(error, "rename SSH item");
@@ -633,7 +641,7 @@ export async function moveSSHItem(
userId?: string, userId?: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
try { try {
const response = await fileManagerApi.put( const response = await getFileManagerApiForSession(sessionId).put(
"/ssh/moveItem", "/ssh/moveItem",
{ {
sessionId, sessionId,
@@ -670,13 +678,10 @@ export async function changeSSHPermissions(
userId, userId,
}); });
const response = await fileManagerApi.post("/ssh/changePermissions", { const response = await getFileManagerApiForSession(sessionId).post(
sessionId, "/ssh/changePermissions",
path, { sessionId, path, permissions, hostId, userId },
permissions, );
hostId,
userId,
});
fileLogger.success("SSH file permissions changed successfully", { fileLogger.success("SSH file permissions changed successfully", {
operation: "change_permissions", operation: "change_permissions",
@@ -715,13 +720,10 @@ export async function extractSSHArchive(
userId, userId,
}); });
const response = await fileManagerApi.post("/ssh/extractArchive", { const response = await getFileManagerApiForSession(sessionId).post(
sessionId, "/ssh/extractArchive",
archivePath, { sessionId, archivePath, extractPath, hostId, userId },
extractPath, );
hostId,
userId,
});
fileLogger.success("Archive extracted successfully", { fileLogger.success("Archive extracted successfully", {
operation: "extract_archive", operation: "extract_archive",
@@ -762,14 +764,17 @@ export async function compressSSHFiles(
userId, userId,
}); });
const response = await fileManagerApi.post("/ssh/compressFiles", { const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/compressFiles",
{
sessionId, sessionId,
paths, paths,
archiveName, archiveName,
format: format || "zip", format: format || "zip",
hostId, hostId,
userId, userId,
}); },
);
fileLogger.success("Files compressed successfully", { fileLogger.success("Files compressed successfully", {
operation: "compress_files", operation: "compress_files",
@@ -811,6 +816,12 @@ export async function ensureSSHSessionForHost(
host: SSHHost, host: SSHHost,
): Promise<EnsureSSHSessionResult> { ): Promise<EnsureSSHSessionResult> {
const sessionId = host.id.toString(); const sessionId = host.id.toString();
const origin = await resolveConnectionOrigin({
connectionType: host.connectionType,
connectionOrigin: host.connectionOrigin,
});
setSessionOrigin(sessionId, origin);
try { try {
const status = await getSSHStatus(sessionId); const status = await getSSHStatus(sessionId);
if (status?.connected) { if (status?.connected) {
+73 -5
View File
@@ -1,5 +1,11 @@
import axios from "axios"; import axios from "axios";
import { authApi, handleApiError, tunnelApi } from "@/main-axios"; import {
authApi,
handleApiError,
tunnelApi,
getRemoteTunnelApi,
isElectron,
} from "@/main-axios";
import type { import type {
C2STunnelPreset, C2STunnelPreset,
TunnelConfig, TunnelConfig,
@@ -9,13 +15,46 @@ import type {
// TUNNEL MANAGEMENT // TUNNEL MANAGEMENT
// ============================================================================ // ============================================================================
//
// Tunnel status is a process-local, in-memory view (no DB lookup) so it's
// safe to read from both the embedded backend and a connected remote server
// and merge the results. connectTunnel/disconnectTunnel/cancelTunnel are
// NOT origin-routed: they resolve the target host by numeric database id
// against whichever backend receives the request, and a synced host has a
// different numeric id in each database (only its syncId matches across
// them) -- routing those calls to a remote backend would need a
// local-id-to-remote-id resolution step that doesn't exist yet. They always
// target the embedded local backend for now.
async function isRemoteSyncConnected(): Promise<boolean> {
if (!isElectron()) return false;
try {
const config = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
return !!config?.serverUrl;
} catch {
return false;
}
}
export async function getTunnelStatuses(): Promise< export async function getTunnelStatuses(): Promise<
Record<string, TunnelStatus> Record<string, TunnelStatus>
> { > {
try { try {
const response = await tunnelApi.get("/tunnel/status"); const [localResult, remoteConnected] = await Promise.all([
return response.data || {}; tunnelApi.get("/tunnel/status"),
isRemoteSyncConnected(),
]);
const localStatuses = localResult.data || {};
if (!remoteConnected) return localStatuses;
try {
const remoteResult = await getRemoteTunnelApi().get("/tunnel/status");
return { ...localStatuses, ...(remoteResult.data || {}) };
} catch {
return localStatuses;
}
} catch (error) { } catch (error) {
handleApiError(error, "fetch tunnel statuses"); handleApiError(error, "fetch tunnel statuses");
} }
@@ -30,9 +69,18 @@ export function subscribeTunnelStatuses(
withCredentials: true, withCredentials: true,
}); });
let latestLocal: Record<string, TunnelStatus> = {};
let latestRemote: Record<string, TunnelStatus> = {};
let remotePollTimer: ReturnType<typeof setInterval> | null = null;
const emitMerged = () => {
onStatuses({ ...latestLocal, ...latestRemote });
};
source.addEventListener("statuses", (event) => { source.addEventListener("statuses", (event) => {
try { try {
onStatuses(JSON.parse(event.data) as Record<string, TunnelStatus>); latestLocal = JSON.parse(event.data) as Record<string, TunnelStatus>;
emitMerged();
} catch { } catch {
onError?.(); onError?.();
} }
@@ -42,7 +90,27 @@ export function subscribeTunnelStatuses(
onError?.(); onError?.();
}; };
return () => source.close(); // Remote tunnel status has no SSE stream exposed to the desktop app yet,
// so poll it at a modest interval when a remote server is connected.
isRemoteSyncConnected().then((connected) => {
if (!connected) return;
const pollRemote = async () => {
try {
const result = await getRemoteTunnelApi().get("/tunnel/status");
latestRemote = result.data || {};
emitMerged();
} catch {
// remote unreachable this tick -- keep last known remote statuses
}
};
pollRemote();
remotePollTimer = setInterval(pollRemote, 5000);
});
return () => {
source.close();
if (remotePollTimer) clearInterval(remotePollTimer);
};
} }
export async function getTunnelStatusByName( export async function getTunnelStatusByName(
+46 -153
View File
@@ -29,17 +29,13 @@ import {
completePasswordReset, completePasswordReset,
getOIDCAuthorizeUrl, getOIDCAuthorizeUrl,
verifyTOTPLogin, verifyTOTPLogin,
getServerConfig,
saveServerConfig,
isElectron, isElectron,
getEmbeddedServerStatus,
getCurrentToken, getCurrentToken,
getOidcSilentLoginDefault, getOidcSilentLoginDefault,
requestDesktopAutoSession,
} from "@/main-axios"; } from "@/main-axios";
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api"; import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
import type { SSOProviderPublic } from "@/types/index"; import type { SSOProviderPublic } from "@/types/index";
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
import { Checkbox } from "@/components/checkbox"; import { Checkbox } from "@/components/checkbox";
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n"; import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import { import {
@@ -263,13 +259,22 @@ export function Auth({ onLogin }: AuthProps) {
const [firstUser, setFirstUser] = useState(false); const [firstUser, setFirstUser] = useState(false);
const [dbConnectionFailed, setDbConnectionFailed] = useState(false); const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
const [dbHealthChecking, setDbHealthChecking] = useState(true); const [dbHealthChecking, setDbHealthChecking] = useState(true);
const [showServerConfig, setShowServerConfig] = useState<boolean | null>(
null,
);
const [currentServerUrl, setCurrentServerUrl] = useState("");
const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false); const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false);
// Electron, non-iframed only: the desktop app never shows a login form
// when running standalone -- the embedded backend auto-provisions a
// single local user on first boot, and this component silently exchanges
// that for a session instead of rendering login/register.
// null = probe still in flight (Electron only, blocks rendering below).
// true = probe settled with no auto-login (not applicable outside
// Electron, multiple users exist, or setup is genuinely required) --
// safe to fall through to the normal form/health-check flow.
// Auto-login success never sets this; it calls onLogin directly and this
// component unmounts.
const [desktopAutoSessionDone, setDesktopAutoSessionDone] = useState<
boolean | null
>(!isElectron() || isInElectronWebView() ? true : null);
useEffect(() => { useEffect(() => {
try { try {
localStorage.setItem("rememberMe", rememberMe.toString()); localStorage.setItem("rememberMe", rememberMe.toString());
@@ -320,7 +325,11 @@ export function Auth({ onLogin }: AuthProps) {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (showServerConfig) return; // Runs once the auto-session probe has settled (immediately outside
// Electron, since it starts at true there; after the probe resolves in
// Electron). Waiting avoids flashing a login screen the user is about
// to skip past via auto-login.
if (desktopAutoSessionDone !== true) return;
setDbHealthChecking(true); setDbHealthChecking(true);
getSetupRequired() getSetupRequired()
.then((res) => { .then((res) => {
@@ -332,53 +341,28 @@ export function Auth({ onLogin }: AuthProps) {
}) })
.catch(() => setDbConnectionFailed(true)) .catch(() => setDbConnectionFailed(true))
.finally(() => setDbHealthChecking(false)); .finally(() => setDbHealthChecking(false));
}, [showServerConfig]); }, [desktopAutoSessionDone]);
useEffect(() => { useEffect(() => {
const checkElectron = async () => { if (desktopAutoSessionDone !== null) return;
if (isInElectronWebView()) { let cancelled = false;
setShowServerConfig(false); requestDesktopAutoSession()
.then((res) => {
if (cancelled) return;
if (res?.success) {
storeAuth(res.username || "");
onLogin(res.username || "", res.userId || undefined, !!res.is_admin);
return; return;
} }
if (isElectron()) { setDesktopAutoSessionDone(true);
const forceShow = localStorage.getItem("termix_show_server_config"); })
if (forceShow === "true") { .catch(() => {
localStorage.removeItem("termix_show_server_config"); if (!cancelled) setDesktopAutoSessionDone(true);
try { });
const config = await getServerConfig(); return () => {
setCurrentServerUrl(config?.serverUrl || ""); cancelled = true;
} catch {
// ignore
}
setShowServerConfig(true);
return;
}
try {
const [config, status] = await Promise.all([
getServerConfig(),
getEmbeddedServerStatus(),
]);
if (
status?.embedded &&
status?.running &&
config &&
!config.serverUrl
) {
setShowServerConfig(false);
setCurrentServerUrl("");
return;
}
setCurrentServerUrl(config?.serverUrl || "");
setShowServerConfig(!config || !config.serverUrl);
} catch {
setShowServerConfig(true);
}
} else {
setShowServerConfig(false);
}
}; };
checkElectron(); }, [desktopAutoSessionDone, onLogin]);
}, []);
useEffect(() => { useEffect(() => {
if (view === "totp" && totpInputRef.current) totpInputRef.current.focus(); if (view === "totp" && totpInputRef.current) totpInputRef.current.focus();
@@ -474,36 +458,6 @@ export function Auth({ onLogin }: AuthProps) {
} }
}, [onLogin, t]); }, [onLogin, t]);
const handleElectronAuthSuccess = useCallback(
async (token: string | null) => {
try {
if (!token) {
// No token in postMessage — fall back to waiting for the HttpOnly cookie
const cookieReady = await window.electronAPI?.waitForSessionCookie?.(
"jwt",
currentServerUrl,
null,
5000,
);
if (cookieReady && !cookieReady.success)
throw new Error(cookieReady.error || "Auth cookie not ready");
}
const meRes = await getUserInfo();
if (!meRes) throw new Error("Failed to get user info");
storeAuth(meRes.username || "");
onLogin(
meRes.username || "",
meRes.userId || undefined,
!!meRes.is_admin,
);
toast.success(t("messages.loginSuccess"));
} catch {
toast.error(t("errors.failedUserInfo"));
}
},
[onLogin, currentServerUrl, t],
);
function resetAll() { function resetAll() {
setUsername(""); setUsername("");
setPassword(""); setPassword("");
@@ -935,46 +889,19 @@ export function Auth({ onLogin }: AuthProps) {
oidcSilentLoginDefaultLoaded, oidcSilentLoginDefaultLoaded,
]); ]);
// Electron server config / webview auth success screens // Electron, non-iframed: wait for the auto-session probe before rendering
if (isElectron() && !isInElectronWebView()) { // anything, so a standalone desktop install never flashes a login form
if (showServerConfig === null) // it's about to skip past.
if (
isElectron() &&
!isInElectronWebView() &&
desktopAutoSessionDone === null
) {
return ( return (
<div className="fixed inset-0 flex items-center justify-center bg-background"> <div className="fixed inset-0 flex items-center justify-center bg-background">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" /> <div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div> </div>
); );
if (showServerConfig)
return (
<div className="fixed inset-0 flex items-center justify-center bg-background p-6">
<div className="w-full max-w-md">
<ServerConfigComponent
onServerConfigured={() => window.location.reload()}
onUseEmbedded={async () => {
await saveServerConfig({
serverUrl: "",
lastUpdated: new Date().toISOString(),
});
setShowServerConfig(false);
setCurrentServerUrl("");
}}
onCancel={() => setShowServerConfig(false)}
isFirstTime={!currentServerUrl}
/>
</div>
</div>
);
if (!webviewAuthSuccess && showServerConfig === false && currentServerUrl)
return (
<div className="w-full h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-4xl h-[90vh]">
<ElectronLoginForm
serverUrl={currentServerUrl}
onAuthSuccess={handleElectronAuthSuccess}
onChangeServer={() => setShowServerConfig(true)}
/>
</div>
</div>
);
} }
if (webviewAuthSuccess || (isInElectronWebView() && webviewAuthSuccess)) if (webviewAuthSuccess || (isInElectronWebView() && webviewAuthSuccess))
@@ -1018,30 +945,11 @@ export function Auth({ onLogin }: AuthProps) {
))} ))}
</select> </select>
</div> </div>
{isElectron() && currentServerUrl && (
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">
{t("serverConfig.serverUrl")}
</span>
<span className="text-xs text-muted-foreground font-mono truncate max-w-[180px]">
{currentServerUrl}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowServerConfig(true)}
>
{t("common.edit")}
</Button>
</div>
)}
</div> </div>
</div> </div>
); );
if (dbHealthChecking && showServerConfig === false) if (dbHealthChecking)
return ( return (
<div className="fixed inset-0 flex items-center justify-center bg-background"> <div className="fixed inset-0 flex items-center justify-center bg-background">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" /> <div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
@@ -1068,21 +976,6 @@ export function Auth({ onLogin }: AuthProps) {
return ( return (
<div className="fixed inset-0 flex flex-col bg-background overflow-hidden"> <div className="fixed inset-0 flex flex-col bg-background overflow-hidden">
{isElectron() && !isInElectronWebView() && showServerConfig === false && (
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<button
onClick={() => setShowServerConfig(true)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="size-4" />
{t("serverConfig.changeServer")}
</button>
<span className="text-xs text-muted-foreground">
{t("serverConfig.localServer")}
</span>
<div className="w-20" />
</div>
)}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Left decorative panel */} {/* Left decorative panel */}
<div className="hidden lg:flex flex-col w-[420px] shrink-0 bg-sidebar border-r border-border relative overflow-hidden select-none"> <div className="hidden lg:flex flex-col w-[420px] shrink-0 bg-sidebar border-r border-border relative overflow-hidden select-none">
+12 -1
View File
@@ -7,6 +7,12 @@ interface ElectronLoginFormProps {
serverUrl: string; serverUrl: string;
onAuthSuccess: (token: string | null) => void | Promise<void>; onAuthSuccess: (token: string | null) => void | Promise<void>;
onChangeServer: () => void; onChangeServer: () => void;
// "local" (default): the app's own login, JWT goes to localStorage like
// every other client. "remoteSync": this iframe is authenticating a
// Settings-triggered connection to a remote Termix server for the sync
// engine -- the JWT is handed to the Electron main process's encrypted
// store instead, never exposed to the renderer's localStorage.
targetPurpose?: "local" | "remoteSync";
} }
const AUTH_MESSAGE_SOURCES = new Set([ const AUTH_MESSAGE_SOURCES = new Set([
@@ -19,6 +25,7 @@ export function ElectronLoginForm({
serverUrl, serverUrl,
onAuthSuccess, onAuthSuccess,
onChangeServer, onChangeServer,
targetPurpose = "local",
}: ElectronLoginFormProps) { }: ElectronLoginFormProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -43,8 +50,12 @@ export function ElectronLoginForm({
try { try {
if (token) { if (token) {
if (targetPurpose === "remoteSync") {
await window.electronAPI?.invoke?.("save-remote-sync-jwt", token);
} else {
localStorage.setItem("jwt", token); localStorage.setItem("jwt", token);
} }
}
await onAuthSuccessRef.current(token); await onAuthSuccessRef.current(token);
} catch { } catch {
setError(t("errors.authTokenSaveFailed")); setError(t("errors.authTokenSaveFailed"));
@@ -53,7 +64,7 @@ export function ElectronLoginForm({
hasAuthenticatedRef.current = false; hasAuthenticatedRef.current = false;
} }
}, },
[t], [t, targetPurpose],
); );
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie. // postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
+1 -3
View File
@@ -9,7 +9,6 @@ import {
getServerConfig, getServerConfig,
saveServerConfig, saveServerConfig,
getEmbeddedServerStatus, getEmbeddedServerStatus,
setEmbeddedMode,
type ServerConfig, type ServerConfig,
} from "@/main-axios.ts"; } from "@/main-axios.ts";
import { Server, Monitor, Loader2, ChevronDown, X } from "lucide-react"; import { Server, Monitor, Loader2, ChevronDown, X } from "lucide-react";
@@ -103,7 +102,7 @@ export function ElectronServerConfig({
const checkEmbeddedBackend = async () => { const checkEmbeddedBackend = async () => {
try { try {
const status = await getEmbeddedServerStatus(); const status = await getEmbeddedServerStatus();
setEmbeddedAvailable(!!status?.embedded); setEmbeddedAvailable(!!status?.running);
} catch { } catch {
setEmbeddedAvailable(true); setEmbeddedAvailable(true);
} }
@@ -144,7 +143,6 @@ export function ElectronServerConfig({
const maxRetries = 15; const maxRetries = 15;
for (let i = 0; i < maxRetries; i++) { for (let i = 0; i < maxRetries; i++) {
if (await probeBackend()) { if (await probeBackend()) {
setEmbeddedMode(true);
if (onUseEmbedded) { if (onUseEmbedded) {
onUseEmbedded(); onUseEmbedded();
} else { } else {
+7 -36
View File
@@ -24,10 +24,8 @@ import {
completePasswordReset, completePasswordReset,
getOIDCAuthorizeUrl, getOIDCAuthorizeUrl,
verifyTOTPLogin, verifyTOTPLogin,
getServerConfig,
saveServerConfig, saveServerConfig,
isElectron, isElectron,
getEmbeddedServerStatus,
getCurrentToken, getCurrentToken,
getOidcSilentLoginDefault, getOidcSilentLoginDefault,
} from "@/main-axios"; } from "@/main-axios";
@@ -1019,41 +1017,14 @@ export function Auth({
}, [dbConnectionFailed, t]); }, [dbConnectionFailed, t]);
useEffect(() => { useEffect(() => {
const checkServerConfig = async () => { // The desktop app always runs its embedded local backend as the source
if (isInElectronWebView()) { // of truth now -- there is no more server-config startup gate here or
// in the top-level Auth.tsx. This component only still renders (a) when
// iframed by ElectronLoginForm as a *remote* server's own web root, in
// which case isInElectronWebView() is true and this branch is skipped
// anyway, or (b) as a re-auth fallback for popped-out fullscreen
// sub-app windows, which should also never show a server picker.
setShowServerConfig(false); setShowServerConfig(false);
return;
}
if (isElectron()) {
try {
const [config, status] = await Promise.all([
getServerConfig(),
getEmbeddedServerStatus(),
]);
if (
status?.embedded &&
status?.running &&
config &&
!config.serverUrl
) {
setCurrentServerUrl("");
setShowServerConfig(false);
return;
}
setCurrentServerUrl(config?.serverUrl || "");
setShowServerConfig(!config || !config.serverUrl);
} catch {
setShowServerConfig(true);
}
} else {
setShowServerConfig(false);
}
};
checkServerConfig();
}, []); }, []);
if (showServerConfig === null && !isInElectronWebView()) { if (showServerConfig === null && !isInElectronWebView()) {
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react";
import { isElectron } from "@/lib/electron";
interface RemoteSyncStatus {
connected: boolean;
syncing: boolean;
lastSyncedAt: string | null;
lastError: string | null;
needsReauth: boolean;
}
// Non-blocking banner shown when a connected remote sync server needs
// re-authentication. Never gates or hides any other UI -- the local app
// keeps working fully regardless of remote sync state.
export function RemoteSyncBanner({ onReconnect }: { onReconnect: () => void }) {
const { t } = useTranslation();
const [status, setStatus] = useState<RemoteSyncStatus | null>(null);
useEffect(() => {
if (!isElectron()) return;
window.electronAPI
?.invoke?.("get-remote-sync-status")
.then((s) => setStatus((s as RemoteSyncStatus) ?? null))
.catch(() => {});
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(
(nextStatus: RemoteSyncStatus) => setStatus(nextStatus),
);
return () => unsubscribe?.();
}, []);
if (!status?.connected || !status.needsReauth) return null;
return (
<div className="flex items-center justify-between gap-3 px-4 py-2 bg-yellow-500/10 border-b border-yellow-500/30 text-xs">
<div className="flex items-center gap-2">
<AlertTriangle className="size-3.5 text-yellow-600 dark:text-yellow-400 shrink-0" />
<span>{t("remoteSync.bannerMessage")}</span>
</div>
<button
type="button"
onClick={onReconnect}
className="font-bold text-accent-brand hover:text-accent-brand/70 transition-colors shrink-0"
>
{t("remoteSync.bannerReconnect")}
</button>
</div>
);
}
+8 -1
View File
@@ -43,6 +43,7 @@ import {
getServiceLinks, getServiceLinks,
createServiceLink, createServiceLink,
deleteServiceLink, deleteServiceLink,
isElectron,
} from "@/main-axios"; } from "@/main-axios";
import type { RecentActivityItem, ServiceLink } from "@/main-axios"; import type { RecentActivityItem, ServiceLink } from "@/main-axios";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -1365,7 +1366,13 @@ export function DashboardTab({
load(); load();
getUserInfo() getUserInfo()
.then((info) => setIsAdmin(!!info.is_admin)) .then((info) => {
// Remote sync is not yet configurable (added in a later phase), so
// a standalone desktop install never shows admin/user-management
// UI -- it has exactly one implicit user and nothing to administer.
const isRemoteSyncConnected = false;
setIsAdmin(!!info.is_admin && (!isElectron() || isRemoteSyncConnected));
})
.catch(() => {}); .catch(() => {});
getUptime() getUptime()
.then((u) => setUptimeFormatted(u.formatted)) .then((u) => setUptimeFormatted(u.formatted))
@@ -15,6 +15,10 @@ import {
} from "@/components/select.tsx"; } from "@/components/select.tsx";
import { Card, CardContent } from "@/components/card.tsx"; import { Card, CardContent } from "@/components/card.tsx";
import { getBasePath } from "@/lib/base-path"; import { getBasePath } from "@/lib/base-path";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
} from "@/lib/connection-origin.ts";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react"; import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { SSHHost } from "@/types"; import type { SSHHost } from "@/types";
@@ -265,7 +269,7 @@ export function ConsoleTerminal({
} }
}, [terminal]); }, [terminal]);
const connect = React.useCallback(() => { const connect = React.useCallback(async () => {
if (!terminal || containerState !== "running") { if (!terminal || containerState !== "running") {
toast.error(t("docker.containerMustBeRunning")); toast.error(t("docker.containerMustBeRunning"));
return; return;
@@ -287,20 +291,30 @@ export function ConsoleTerminal({
window.location.port === "5173" || window.location.port === "5173" ||
window.location.port === ""); window.location.port === "");
const baseWsUrl = isDev let baseWsUrl: string;
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009` if (isDev) {
: isElectronApp baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`;
? (() => { } else if (isElectronApp) {
const baseUrl = const origin = await resolveConnectionOrigin({
(window as { configuredServerUrl?: string }) connectionType: "ssh",
.configuredServerUrl || "http://127.0.0.1:30001"; connectionOrigin: hostConfig.connectionOrigin,
const wsProtocol = baseUrl.startsWith("https://") });
? "wss://" const resolvedUrl = await buildOriginWsUrl({
: "ws://"; origin,
const wsHost = baseUrl.replace(/^https?:\/\//, ""); localPort: 30009,
return `${wsProtocol}${wsHost}/docker/console/`; localPath: "/docker/console/",
})() remotePath: "/docker/console/",
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`; includeLocalJwt: false,
});
if (!resolvedUrl) {
setIsConnecting(false);
toast.error(t("errors.remoteServerRequired"));
return;
}
baseWsUrl = resolvedUrl;
} else {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
}
const ws = new WebSocket(baseWsUrl); const ws = new WebSocket(baseWsUrl);
+28 -7
View File
@@ -15,7 +15,9 @@ import {
getGuacdStatus, getGuacdStatus,
getSSHHosts, getSSHHosts,
logActivity, logActivity,
isElectron,
} from "@/main-axios.ts"; } from "@/main-axios.ts";
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertCircle, RefreshCw } from "lucide-react"; import { AlertCircle, RefreshCw } from "lucide-react";
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx"; import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
@@ -172,19 +174,34 @@ const GuacamoleAppInner = React.forwardRef<
setToken(null); setToken(null);
setGuacamoleConnectionId(null); setGuacamoleConnectionId(null);
setError(null); setError(null);
getGuacdStatus()
.then((status) => { (async () => {
if (isElectron()) {
const origin = await resolveConnectionOrigin({
connectionType: resolvedProtocolForConnect,
});
if (origin === "remote") {
const remoteConfig = (await window.electronAPI?.invoke?.(
"get-remote-sync-config",
)) as { serverUrl?: string } | null;
if (!remoteConfig?.serverUrl) {
setError(t("errors.remoteServerRequired"));
return;
}
}
}
try {
const status = await getGuacdStatus();
if (status.guacd.status !== "connected") { if (status.guacd.status !== "connected") {
setError(t("guacamole.guacdUnavailable")); setError(t("guacamole.guacdUnavailable"));
return; return;
} }
return getGuacamoleTokenFromHost( const result = await getGuacamoleTokenFromHost(
hostId, hostId,
protocol, protocol,
promptedCredentials ?? undefined, promptedCredentials ?? undefined,
); );
})
.then((result) => {
if (result) { if (result) {
setToken(result.token); setToken(result.token);
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null); setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
@@ -192,8 +209,12 @@ const GuacamoleAppInner = React.forwardRef<
() => {}, () => {},
); );
} }
}) } catch (err: unknown) {
.catch((err) => setError(err?.message || t("guacamole.failedToConnect"))); const message =
err instanceof Error ? err.message : t("guacamole.failedToConnect");
setError(message || t("guacamole.failedToConnect"));
}
})();
}, [ }, [
hostId, hostId,
hostName, hostName,
+27 -7
View File
@@ -8,10 +8,14 @@ import {
} from "react"; } from "react";
import Guacamole from "guacamole-common-js"; import Guacamole from "guacamole-common-js";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getGuacamoleToken, isElectron, isEmbeddedMode } from "@/main-axios.ts"; import { getGuacamoleToken, isElectron } from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { getBasePath } from "@/lib/base-path.ts"; import { getBasePath } from "@/lib/base-path.ts";
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts"; import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
} from "@/lib/connection-origin.ts";
import { import {
isFirefoxBrowser, isFirefoxBrowser,
isPasteShortcut, isPasteShortcut,
@@ -169,15 +173,31 @@ export const GuacamoleDisplay = forwardRef<
connectionConfig.dpi, connectionConfig.dpi,
); );
const wsBase = buildGuacamoleWebSocketBaseUrl({ let wsBase: string | null;
if (isElectron()) {
const origin = await resolveConnectionOrigin({
connectionType: connectionProtocol,
});
wsBase = await buildOriginWsUrl({
origin,
localPort: 30008,
localPath: "/guacamole/websocket/",
remotePath: "/guacamole/websocket/",
includeLocalJwt: false,
});
if (!wsBase) {
onError?.(t("errors.remoteServerRequired"));
return null;
}
} else {
wsBase = buildGuacamoleWebSocketBaseUrl({
isDev, isDev,
isElectronApp: isElectron(), isElectronApp: false,
isEmbeddedApp: isEmbeddedMode(), isEmbeddedApp: false,
configuredServerUrl: (window as { configuredServerUrl?: string })
.configuredServerUrl,
basePath: getBasePath(), basePath: getBasePath(),
location: window.location, location: window.location,
}); });
}
const params = new URLSearchParams({ const params = new URLSearchParams({
token, token,
@@ -193,7 +213,7 @@ export const GuacamoleDisplay = forwardRef<
return null; return null;
} }
}, },
[connectionConfig, onError], [connectionConfig, onError, t],
); );
const refreshKeyboardHandlers = useCallback(() => { const refreshKeyboardHandlers = useCallback(() => {
+2 -23
View File
@@ -10,7 +10,6 @@ import { FitAddon } from "@xterm/addon-fit";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TriangleAlert } from "lucide-react"; import { TriangleAlert } from "lucide-react";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { isEmbeddedMode } from "@/main-axios";
import { useTheme } from "@/components/theme-provider"; import { useTheme } from "@/components/theme-provider";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme"; import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes"; import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
@@ -101,31 +100,11 @@ export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
// ── WebSocket (Electron) path ────────────────────────────────────────── // ── WebSocket (Electron) path ──────────────────────────────────────────
const buildWsUrl = useCallback(() => { const buildWsUrl = useCallback(() => {
const isDev = // Serial is always local -- the device is physically attached to this
!isElectron() && // desktop machine, so it never routes through a remote server.
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
if (isDev || isEmbeddedMode()) {
const token = localStorage.getItem("jwt"); const token = localStorage.getItem("jwt");
const base = "ws://127.0.0.1:30011"; const base = "ws://127.0.0.1:30011";
return token ? `${base}?token=${encodeURIComponent(token)}` : base; return token ? `${base}?token=${encodeURIComponent(token)}` : base;
}
const configuredUrl = (window as { configuredServerUrl?: string | null })
.configuredServerUrl;
if (!configuredUrl) return null;
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
const token = localStorage.getItem("jwt");
const base = `${wsProtocol}${wsHost}/serial/websocket/`;
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
}, []); }, []);
const disconnectWs = useCallback(() => { const disconnectWs = useCallback(() => {
@@ -11,7 +11,6 @@ import {
import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { getBasePath } from "@/lib/base-path"; import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { getServerConfig } from "@/main-axios";
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx"; import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
const PING_INTERVAL_MS = 30000; const PING_INTERVAL_MS = 30000;
@@ -24,6 +23,9 @@ interface TerminalWsMessage {
// Mirrors Terminal.tsx's baseWsUrl construction (dev/electron/embedded/prod). // Mirrors Terminal.tsx's baseWsUrl construction (dev/electron/embedded/prod).
// Duplicated rather than extracted from that file to avoid touching it here. // Duplicated rather than extracted from that file to avoid touching it here.
// A shared session link is always resolved against the desktop app's
// embedded local backend -- joining a session hosted on someone else's
// remote server isn't supported from the desktop app today.
async function resolveTerminalWsBaseUrl(): Promise<string> { async function resolveTerminalWsBaseUrl(): Promise<string> {
const isDev = const isDev =
!isElectron() && !isElectron() &&
@@ -36,17 +38,6 @@ async function resolveTerminalWsBaseUrl(): Promise<string> {
return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`; return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
} }
if (isElectron()) { if (isElectron()) {
const serverConfig = await getServerConfig();
const configuredUrl = serverConfig?.serverUrl;
if (configuredUrl) {
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredUrl
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
return `${wsProtocol}${wsHost}/ssh/websocket/`;
}
return "ws://127.0.0.1:30002"; return "ws://127.0.0.1:30002";
} }
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws"; const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
+21 -43
View File
@@ -17,16 +17,18 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getBasePath } from "@/lib/base-path"; import { getBasePath } from "@/lib/base-path";
import {
resolveConnectionOrigin,
buildOriginWsUrl,
} from "@/lib/connection-origin.ts";
import { import {
getCookie, getCookie,
isElectron, isElectron,
isEmbeddedMode,
logActivity, logActivity,
getSnippets, getSnippets,
deleteCommandFromHistory, deleteCommandFromHistory,
getCommandHistory, getCommandHistory,
getHostPassword, getHostPassword,
getServerConfig,
} from "@/main-axios.ts"; } from "@/main-axios.ts";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx"; import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx"; import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
@@ -973,52 +975,28 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (isDev) { if (isDev) {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`; baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
} else if (isElectron()) { } else if (isElectron()) {
let configuredUrl = (window as { configuredServerUrl?: string | null }) const origin = await resolveConnectionOrigin({
.configuredServerUrl; connectionType: "ssh",
connectionOrigin: hostConfig.connectionOrigin as
if (!configuredUrl && !isEmbeddedMode()) { | "local"
try { | "remote"
const serverConfig = await getServerConfig(); | null
configuredUrl = serverConfig?.serverUrl || null; | undefined,
if (configuredUrl) { });
( const resolvedUrl = await buildOriginWsUrl({
window as Window & origin,
typeof globalThis & { localPort: 30002,
configuredServerUrl?: string | null; localPath: "",
} remotePath: "/ssh/websocket/",
).configuredServerUrl = configuredUrl; });
} if (!resolvedUrl) {
} catch (error) {
console.error("Failed to resolve Electron server URL:", error);
}
}
if (isEmbeddedMode()) {
baseWsUrl = "ws://127.0.0.1:30002";
const storedJwt = localStorage.getItem("jwt");
if (storedJwt) {
baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`;
}
} else if (!configuredUrl) {
console.error("No configured server URL available for Electron SSH");
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); setIsConnecting(false);
updateConnectionError(t("errors.failedToLoadServer")); updateConnectionError(t("errors.remoteServerRequired"));
isConnectingRef.current = false; isConnectingRef.current = false;
return; return;
} else {
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredUrl
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
baseWsUrl = `${wsProtocol}${wsHost}/ssh/websocket/`;
const storedJwt = localStorage.getItem("jwt");
if (storedJwt) {
baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`;
}
} }
baseWsUrl = resolvedUrl;
} else { } else {
baseWsUrl = `${getBasePath()}/ssh/websocket/`; baseWsUrl = `${getBasePath()}/ssh/websocket/`;
} }
+112
View File
@@ -0,0 +1,112 @@
import { isElectron } from "@/lib/electron";
export type ConnectionOrigin = "local" | "remote";
interface OriginResolvableHost {
connectionType?: string | null;
connectionOrigin?: ConnectionOrigin | null;
}
/**
* Resolves which backend a given host's interactive connection (SSH,
* Docker console, Serial) should dial: the desktop app's embedded local
* backend, or a connected remote sync server.
*
* RDP/VNC/Telnet always resolve to "remote" -- guacd isn't bundled with the
* embedded backend. Serial always resolves to "local" -- the hardware is
* physically attached to this desktop machine. Everything else follows the
* host's own override if set, falling back to the desktop-wide default.
*/
export async function resolveConnectionOrigin(
host: OriginResolvableHost,
): Promise<ConnectionOrigin> {
if (
host.connectionType === "rdp" ||
host.connectionType === "vnc" ||
host.connectionType === "telnet"
) {
return "remote";
}
if (host.connectionType === "serial") {
return "local";
}
if (!isElectron()) {
return "local";
}
if (host.connectionOrigin === "local" || host.connectionOrigin === "remote") {
return host.connectionOrigin;
}
try {
const settings = (await window.electronAPI?.invoke?.(
"get-desktop-settings",
)) as { defaultConnectionOrigin?: ConnectionOrigin } | null;
return settings?.defaultConnectionOrigin === "remote" ? "remote" : "local";
} catch {
return "local";
}
}
export interface RemoteConnectionTarget {
serverUrl: string;
jwt: string | null;
}
async function getRemoteConnectionTarget(): Promise<RemoteConnectionTarget | null> {
try {
const [config, jwt] = await Promise.all([
window.electronAPI?.invoke?.("get-remote-sync-config") as Promise<{
serverUrl?: string;
} | null>,
window.electronAPI?.invoke?.("get-remote-sync-jwt") as Promise<
string | null
>,
]);
if (!config?.serverUrl) return null;
return { serverUrl: config.serverUrl, jwt: jwt ?? null };
} catch {
return null;
}
}
/**
* Builds the base WebSocket URL for an interactive connection protocol,
* given a resolved origin. Returns null when origin is "remote" but no
* remote server is connected -- callers must show a blocking message
* rather than attempting to connect.
*/
export async function buildOriginWsUrl({
origin,
localPort,
localPath,
remotePath,
includeLocalJwt = true,
}: {
origin: ConnectionOrigin;
localPort: number;
localPath: string;
remotePath: string;
includeLocalJwt?: boolean;
}): Promise<string | null> {
if (origin === "local") {
let url = `ws://127.0.0.1:${localPort}${localPath}`;
if (includeLocalJwt) {
const token = localStorage.getItem("jwt");
if (token) url += `?token=${encodeURIComponent(token)}`;
}
return url;
}
const remote = await getRemoteConnectionTarget();
if (!remote) return null;
const wsProtocol = remote.serverUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = remote.serverUrl
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
let url = `${wsProtocol}${wsHost}${remotePath}`;
if (remote.jwt) url += `?token=${encodeURIComponent(remote.jwt)}`;
return url;
}
+38 -1
View File
@@ -389,6 +389,37 @@
"noSavedServers": "No saved servers", "noSavedServers": "No saved servers",
"removeServer": "Remove" "removeServer": "Remove"
}, },
"remoteSync": {
"title": "Remote Sync",
"description": "Optionally connect this desktop app to a self-hosted Termix server to sync your hosts, credentials, and snippets across devices. The app always works fully offline whether or not you connect.",
"notConnected": "Not connected",
"connected": "Connected",
"connectedTo": "Connected to {{url}}",
"lastSynced": "Last synced {{time}}",
"neverSynced": "Never synced",
"syncError": "Sync error: {{message}}",
"needsReauth": "Sign-in expired",
"connectButton": "Connect to Server",
"disconnectButton": "Disconnect",
"syncNowButton": "Sync Now",
"syncing": "Syncing...",
"serverUrl": "Server URL",
"enterServerUrl": "Please enter a server URL",
"mustIncludeProtocol": "Server URL must start with http:// or https://",
"allowInvalidCertificate": "Allow invalid certificate",
"allowInvalidCertificateDesc": "Use only for trusted self-hosted servers with self-signed or IP-address certificates.",
"savedServers": "Saved Servers",
"removeServer": "Remove",
"continueButton": "Continue",
"cancelButton": "Cancel",
"signInTitle": "Sign in to {{url}}",
"originTitle": "Connection Origin",
"originDescription": "Choose where SSH connections originate from by default. This can be overridden per host.",
"originLocal": "This device (local network)",
"originRemote": "Remote server",
"bannerReconnect": "Reconnect",
"bannerMessage": "Remote sync needs re-authentication"
},
"versionCheck": { "versionCheck": {
"error": "Version Check Error", "error": "Version Check Error",
"checkFailed": "Failed to check for updates", "checkFailed": "Failed to check for updates",
@@ -651,6 +682,11 @@
"delayAfterMs": "Delay After (ms)", "delayAfterMs": "Delay After (ms)",
"useSocks5Proxy": "Use SOCKS5 Proxy", "useSocks5Proxy": "Use SOCKS5 Proxy",
"useSocks5ProxyDesc": "Route connection through a proxy server", "useSocks5ProxyDesc": "Route connection through a proxy server",
"connectionOrigin": "Connection Origin",
"connectionOriginDesc": "Where this host's SSH connection originates from. Overrides the desktop app's global default.",
"connectionOriginDefault": "Use default",
"connectionOriginLocal": "This device (local network)",
"connectionOriginRemote": "Remote server",
"proxyHost": "Proxy Host", "proxyHost": "Proxy Host",
"proxyPort": "Proxy Port", "proxyPort": "Proxy Port",
"proxyUsername": "Proxy Username", "proxyUsername": "Proxy Username",
@@ -2360,7 +2396,8 @@
"resetCodeRateLimited": "Rate limited: Too many verification attempts. Please try again later.", "resetCodeRateLimited": "Rate limited: Too many verification attempts. Please try again later.",
"resetCodeRateLimitedWithTime": "Rate limited: Too many verification attempts. Please wait {{time}} seconds before trying again.", "resetCodeRateLimitedWithTime": "Rate limited: Too many verification attempts. Please wait {{time}} seconds before trying again.",
"authTokenSaveFailed": "Failed to save authentication token", "authTokenSaveFailed": "Failed to save authentication token",
"failedToLoadServer": "Failed to load server" "failedToLoadServer": "Failed to load server",
"remoteServerRequired": "Remote server required. Connect a remote server in Settings to use this connection type."
}, },
"messages": { "messages": {
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.", "registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.",
+170 -106
View File
@@ -51,7 +51,6 @@ import {
tunnelLogger, tunnelLogger,
fileLogger, fileLogger,
statsLogger, statsLogger,
systemLogger,
dashboardLogger, dashboardLogger,
type LogContext, type LogContext,
} from "@/lib/frontend-logger"; } from "@/lib/frontend-logger";
@@ -646,8 +645,6 @@ function isDev(): boolean {
} }
const apiHost = import.meta.env.VITE_API_HOST || "localhost"; const apiHost = import.meta.env.VITE_API_HOST || "localhost";
let configuredServerUrl: string | null = null;
let embeddedMode = false;
export interface ServerConfig { export interface ServerConfig {
serverUrl: string; serverUrl: string;
@@ -665,6 +662,13 @@ interface AxiosErrorExtended extends AxiosError {
config?: AxiosRequestConfigExtended; config?: AxiosRequestConfigExtended;
} }
/**
* Reads the remote-sync connection record (repurposed from the old
* server-config.json startup-gate file). Not used to route the app's own
* API instances anymore -- see getApiUrl, which always targets the
* embedded backend in Electron. This is only consumed by the Remote Sync
* settings panel (Phase 3).
*/
export async function getServerConfig(): Promise<ServerConfig | null> { export async function getServerConfig(): Promise<ServerConfig | null> {
if (!isElectron()) return null; if (!isElectron()) return null;
@@ -674,7 +678,6 @@ export async function getServerConfig(): Promise<ServerConfig | null> {
typeof globalThis & { typeof globalThis & {
IS_ELECTRON?: boolean; IS_ELECTRON?: boolean;
electronAPI?: unknown; electronAPI?: unknown;
configuredServerUrl?: string;
} }
).electronAPI?.invoke("get-server-config"); ).electronAPI?.invoke("get-server-config");
return result; return result;
@@ -693,33 +696,15 @@ export async function saveServerConfig(config: ServerConfig): Promise<boolean> {
typeof globalThis & { typeof globalThis & {
IS_ELECTRON?: boolean; IS_ELECTRON?: boolean;
electronAPI?: unknown; electronAPI?: unknown;
configuredServerUrl?: string;
} }
).electronAPI?.invoke("save-server-config", config); ).electronAPI?.invoke("save-server-config", config);
if (result?.success) { return !!result?.success;
configuredServerUrl = config.serverUrl;
(
window as Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: unknown;
configuredServerUrl?: string;
}
).configuredServerUrl = configuredServerUrl;
updateApiInstances();
return true;
}
return false;
} catch (error) { } catch (error) {
console.error("Failed to save server config:", error); console.error("Failed to save server config:", error);
return false; return false;
} }
} }
export function getConfiguredServerUrl(): string | null {
return configuredServerUrl;
}
export async function testServerConnection( export async function testServerConnection(
serverUrl: string, serverUrl: string,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
@@ -732,7 +717,6 @@ export async function testServerConnection(
typeof globalThis & { typeof globalThis & {
IS_ELECTRON?: boolean; IS_ELECTRON?: boolean;
electronAPI?: unknown; electronAPI?: unknown;
configuredServerUrl?: string;
} }
).electronAPI?.invoke("test-server-connection", serverUrl); ).electronAPI?.invoke("test-server-connection", serverUrl);
return result; return result;
@@ -767,7 +751,6 @@ export async function checkElectronUpdate(): Promise<{
typeof globalThis & { typeof globalThis & {
IS_ELECTRON?: boolean; IS_ELECTRON?: boolean;
electronAPI?: unknown; electronAPI?: unknown;
configuredServerUrl?: string;
} }
).electronAPI?.invoke("check-electron-update"); ).electronAPI?.invoke("check-electron-update");
return result; return result;
@@ -777,9 +760,13 @@ export async function checkElectronUpdate(): Promise<{
} }
} }
/**
* Reports whether the local embedded backend process is alive. The desktop
* app always runs the embedded backend now, so this is purely a liveness
* check -- it no longer reflects a "mode" the app could be in.
*/
export async function getEmbeddedServerStatus(): Promise<{ export async function getEmbeddedServerStatus(): Promise<{
running: boolean; running: boolean;
embedded: boolean;
dataDir: string | null; dataDir: string | null;
} | null> { } | null> {
if (!isElectron()) return null; if (!isElectron()) return null;
@@ -796,7 +783,6 @@ export async function getEmbeddedServerStatus(): Promise<{
).electronAPI?.invoke("get-embedded-server-status"); ).electronAPI?.invoke("get-embedded-server-status");
return result as { return result as {
running: boolean; running: boolean;
embedded: boolean;
dataDir: string | null; dataDir: string | null;
} | null; } | null;
} catch { } catch {
@@ -804,33 +790,16 @@ export async function getEmbeddedServerStatus(): Promise<{
} }
} }
export function isEmbeddedMode(): boolean {
return embeddedMode;
}
export function setEmbeddedMode(value: boolean): void {
embeddedMode = value;
if (value) {
configuredServerUrl = null;
initializeApiInstances();
}
}
function getApiUrl(path: string, defaultPort: number): string { function getApiUrl(path: string, defaultPort: number): string {
const devMode = isDev(); const devMode = isDev();
const electronMode = isElectron(); const electronMode = isElectron();
if (electronMode) { if (electronMode) {
if (embeddedMode && !configuredServerUrl) { // The desktop app always runs its embedded local backend as the
// source of truth. A configured remote sync server is a separate,
// narrow connection used only by the sync engine (see
// remote-sync-axios.ts), not by these shared instances.
return `http://localhost:${defaultPort}${path}`; return `http://localhost:${defaultPort}${path}`;
}
if (configuredServerUrl) {
const baseUrl = configuredServerUrl.replace(/\/$/, "");
const url = `${baseUrl}${path}`;
return url;
}
console.warn("Electron mode but no server configured!");
return "http://no-server-configured";
} else if (devMode) { } else if (devMode) {
const protocol = window.location.protocol === "https:" ? "https" : "http"; const protocol = window.location.protocol === "https:" ? "https" : "http";
const sslPort = protocol === "https" ? 8443 : defaultPort; const sslPort = protocol === "https" ? 8443 : defaultPort;
@@ -841,6 +810,116 @@ function getApiUrl(path: string, defaultPort: number): string {
} }
} }
// ============================================================================
// PER-HOST ORIGIN ROUTING (Electron desktop only)
// ============================================================================
//
// hostApi/fileManagerApi/tunnelApi/statsApi above always point at the
// embedded local backend -- they're the shared, always-on instances. When a
// host's connection origin resolves to "remote" (see
// src/ui/lib/connection-origin.ts), the backend that actually holds that
// host's live SSH session is the connected remote server instead, so file
// manager, tunnel, and stats calls for that host must follow it there.
//
// These dynamically-baseURL'd instances resolve the remote server's URL and
// JWT fresh on every request (cheap, and correct even if the user
// connects/disconnects remote sync without an app reload).
function createRemoteOriginApiInstance(path: string): AxiosInstance {
const instance = axios.create({
headers: { "Content-Type": "application/json" },
timeout: 30000,
});
instance.interceptors.request.use(async (config: AxiosRequestConfig) => {
const [remoteConfig, remoteJwt] = await Promise.all([
window.electronAPI?.invoke?.("get-remote-sync-config") as Promise<{
serverUrl?: string;
} | null>,
window.electronAPI?.invoke?.("get-remote-sync-jwt") as Promise<
string | null
>,
]);
const baseUrl = (remoteConfig?.serverUrl || "").replace(/\/$/, "");
config.baseURL = baseUrl
? `${baseUrl}${path}`
: "http://no-server-configured";
if (config.headers.set) {
config.headers.set("X-Electron-App", "true");
if (remoteJwt) config.headers.set("Authorization", `Bearer ${remoteJwt}`);
} else {
config.headers["X-Electron-App"] = "true";
if (remoteJwt) config.headers["Authorization"] = `Bearer ${remoteJwt}`;
}
return config;
});
return instance;
}
let remoteFileManagerApi: AxiosInstance | null = null;
let remoteTunnelApi: AxiosInstance | null = null;
let remoteStatsApi: AxiosInstance | null = null;
export function getRemoteFileManagerApi(): AxiosInstance {
if (!remoteFileManagerApi) {
remoteFileManagerApi = createRemoteOriginApiInstance("/ssh/file_manager");
}
return remoteFileManagerApi;
}
export function getRemoteTunnelApi(): AxiosInstance {
if (!remoteTunnelApi) {
remoteTunnelApi = createRemoteOriginApiInstance("/ssh");
}
return remoteTunnelApi;
}
export function getRemoteStatsApi(): AxiosInstance {
if (!remoteStatsApi) {
remoteStatsApi = createRemoteOriginApiInstance("");
}
return remoteStatsApi;
}
// Maps a live SSH session (keyed by sessionId, which today is the host's
// numeric id as a string -- see ensureSSHSessionForHost) to the resolved
// origin it was connected through, so every subsequent file-manager call
// for that session reaches the backend that actually holds it.
const sessionOrigins = new Map<string, "local" | "remote">();
export function setSessionOrigin(
sessionId: string,
origin: "local" | "remote",
): void {
sessionOrigins.set(sessionId, origin);
}
export function clearSessionOrigin(sessionId: string): void {
sessionOrigins.delete(sessionId);
}
export function getFileManagerApiForSession(sessionId: string): AxiosInstance {
return sessionOrigins.get(sessionId) === "remote"
? getRemoteFileManagerApi()
: fileManagerApi;
}
export function getTunnelApiForOrigin(
origin: "local" | "remote",
): AxiosInstance {
return origin === "remote" ? getRemoteTunnelApi() : tunnelApi;
}
export function getStatsApiForOrigin(
origin: "local" | "remote",
): AxiosInstance {
return origin === "remote" ? getRemoteStatsApi() : statsApi;
}
function initializeApiInstances() { function initializeApiInstances() {
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet // Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST"); hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST");
@@ -921,43 +1000,8 @@ export const appReadyPromise: Promise<void> = new Promise((resolve) => {
}); });
function initializeApp() { function initializeApp() {
if (isElectron()) {
Promise.all([getServerConfig(), getEmbeddedServerStatus()])
.then(([config, status]) => {
if (status?.embedded && status?.running && !config?.serverUrl) {
embeddedMode = true;
}
if (config?.serverUrl) {
configuredServerUrl = config.serverUrl;
(
window as Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: unknown;
configuredServerUrl?: string;
}
).configuredServerUrl = configuredServerUrl;
} else if (embeddedMode) {
// Embedded backend running, no remote server needed
} else {
console.warn("No server URL in config");
}
initializeApiInstances();
})
.catch((error) => {
console.error(
"Failed to load server config, initializing with default:",
error,
);
initializeApiInstances();
})
.finally(() => {
_resolveAppReady();
});
} else {
initializeApiInstances(); initializeApiInstances();
_resolveAppReady(); _resolveAppReady();
}
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {
@@ -966,29 +1010,6 @@ if (document.readyState === "loading") {
initializeApp(); initializeApp();
} }
function updateApiInstances() {
systemLogger.info("Updating API instances with new server configuration", {
operation: "api_instance_update",
configuredServerUrl,
});
initializeApiInstances();
(
window as Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: unknown;
configuredServerUrl?: string;
}
).configuredServerUrl = configuredServerUrl;
systemLogger.success("All API instances updated successfully", {
operation: "api_instance_update_complete",
configuredServerUrl,
});
}
// ============================================================================ // ============================================================================
// ERROR HANDLING // ERROR HANDLING
// ============================================================================ // ============================================================================
@@ -1856,6 +1877,49 @@ export async function getSetupRequired(): Promise<{ setup_required: boolean }> {
} }
} }
// Module-level (not per-component) so concurrent/duplicate mounts -- e.g.
// React StrictMode's intentional double-invoke of effects in dev, or any
// other double-call -- share one in-flight request instead of each minting
// its own session. Minting more than one session here is not just wasted
// work: the backend sets a fresh `jwt` cookie on every call, and since the
// auth middleware prefers the cookie over the Authorization header, a
// second mint silently invalidates whichever token the app already started
// using, surfacing as a spurious "session expired/revoked" on the very
// next request.
let desktopAutoSessionRequest: Promise<AuthResponse | null> | null = null;
/**
* Electron-only, non-iframed local login: exchanges the embedded backend's
* single auto-provisioned local user for a session without ever showing a
* login form. Only succeeds when exactly one user exists locally (a synced
* or otherwise multi-user install never satisfies this, and falls through
* to a normal login screen instead).
*/
export async function requestDesktopAutoSession(): Promise<AuthResponse | null> {
if (desktopAutoSessionRequest) return desktopAutoSessionRequest;
desktopAutoSessionRequest = (async () => {
try {
const response = await authApi.post("/users/internal/auto-session");
if (response.data?.token) {
localStorage.setItem("jwt", response.data.token);
}
if (response.data?.success) {
markUserAuthenticated();
}
return response.data;
} catch {
return null;
}
})();
try {
return await desktopAutoSessionRequest;
} finally {
desktopAutoSessionRequest = null;
}
}
export async function getUserCount(): Promise<UserCount> { export async function getUserCount(): Promise<UserCount> {
try { try {
const response = await authApi.get("/users/count"); const response = await authApi.get("/users/count");
+269
View File
@@ -0,0 +1,269 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Server, RefreshCw, Loader2 } from "lucide-react";
import { Button } from "@/components/button.tsx";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/dialog.tsx";
import { RemoteSyncServerPicker } from "./RemoteSyncServerPicker.tsx";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx";
interface RemoteSyncConfig {
serverUrl: string;
connectedAt: string;
lastSyncedAt?: string | null;
lastSyncStatus?: "ok" | "error" | "never";
lastSyncError?: string | null;
}
interface RemoteSyncStatus {
connected: boolean;
syncing: boolean;
lastSyncedAt: string | null;
lastError: string | null;
needsReauth: boolean;
}
type DesktopSettings = { defaultConnectionOrigin: "local" | "remote" };
export function RemoteSyncPanel() {
const { t } = useTranslation();
const [config, setConfig] = useState<RemoteSyncConfig | null>(null);
const [status, setStatus] = useState<RemoteSyncStatus | null>(null);
const [desktopSettings, setDesktopSettings] = useState<DesktopSettings>({
defaultConnectionOrigin: "local",
});
const [step, setStep] = useState<"idle" | "picker" | "login">("idle");
const [pendingServerUrl, setPendingServerUrl] = useState("");
const [syncingNow, setSyncingNow] = useState(false);
const refresh = useCallback(async () => {
const [cfg, st, settings] = await Promise.all([
window.electronAPI?.invoke?.(
"get-remote-sync-config",
) as Promise<RemoteSyncConfig | null>,
window.electronAPI?.invoke?.(
"get-remote-sync-status",
) as Promise<RemoteSyncStatus | null>,
window.electronAPI?.invoke?.(
"get-desktop-settings",
) as Promise<DesktopSettings>,
]);
setConfig(cfg ?? null);
setStatus(st ?? null);
if (settings) setDesktopSettings(settings);
}, []);
useEffect(() => {
refresh();
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(
(nextStatus: RemoteSyncStatus) => setStatus(nextStatus),
);
return () => unsubscribe?.();
}, [refresh]);
const handleConnectClick = () => setStep("picker");
const handleServerConfigured = (serverUrl: string) => {
setPendingServerUrl(serverUrl);
setStep("login");
};
const handleAuthSuccess = async () => {
setStep("idle");
await refresh();
};
const handleDisconnect = async () => {
await window.electronAPI?.invoke?.("clear-remote-sync-config");
await refresh();
};
const handleSyncNow = async () => {
setSyncingNow(true);
try {
await window.electronAPI?.invoke?.("remote-sync-now");
} finally {
setSyncingNow(false);
await refresh();
}
};
const handleOriginChange = async (origin: "local" | "remote") => {
const next = { ...desktopSettings, defaultConnectionOrigin: origin };
setDesktopSettings(next);
await window.electronAPI?.invoke?.("save-desktop-settings", next);
};
const isConnected = !!config?.serverUrl;
return (
<div className="flex flex-col gap-4">
<div className="border border-border bg-muted/10 p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<Server className="size-4 text-accent-brand" />
<p className="font-bold text-sm">{t("remoteSync.title")}</p>
</div>
<p className="text-xs text-muted-foreground">
{t("remoteSync.description")}
</p>
<div className="flex items-center justify-between border-t border-border pt-3">
<div className="flex flex-col gap-0.5">
{isConnected ? (
<>
<span className="text-xs font-medium">
{t("remoteSync.connectedTo", { url: config.serverUrl })}
</span>
<span className="text-[10px] text-muted-foreground">
{status?.needsReauth
? t("remoteSync.needsReauth")
: status?.lastError
? t("remoteSync.syncError", {
message: status.lastError,
})
: status?.lastSyncedAt
? t("remoteSync.lastSynced", {
time: new Date(
status.lastSyncedAt,
).toLocaleString(),
})
: t("remoteSync.neverSynced")}
</span>
</>
) : (
<span className="text-xs text-muted-foreground">
{t("remoteSync.notConnected")}
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{isConnected ? (
<>
{status?.needsReauth && (
<Button
type="button"
size="sm"
className="text-[10px] h-7"
onClick={handleConnectClick}
>
{t("remoteSync.bannerReconnect")}
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
className="text-[10px] h-7"
onClick={handleSyncNow}
disabled={syncingNow || status?.syncing}
>
{syncingNow || status?.syncing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{t("remoteSync.syncNowButton")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="text-[10px] h-7"
onClick={handleDisconnect}
>
{t("remoteSync.disconnectButton")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="text-[10px] h-7"
onClick={handleConnectClick}
>
<Server className="size-3" />
{t("remoteSync.connectButton")}
</Button>
)}
</div>
</div>
</div>
<div className="border border-border bg-muted/10 p-4 flex flex-col gap-3">
<p className="font-bold text-sm">{t("remoteSync.originTitle")}</p>
<p className="text-xs text-muted-foreground">
{t("remoteSync.originDescription")}
</p>
<div className="flex border border-border overflow-hidden w-fit">
<button
type="button"
onClick={() => handleOriginChange("local")}
className={`px-3 py-1.5 text-xs font-bold uppercase tracking-widest transition-colors ${
desktopSettings.defaultConnectionOrigin === "local"
? "bg-accent-brand text-background"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
{t("remoteSync.originLocal")}
</button>
<button
type="button"
onClick={() => handleOriginChange("remote")}
disabled={!isConnected}
className={`px-3 py-1.5 text-xs font-bold uppercase tracking-widest transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
desktopSettings.defaultConnectionOrigin === "remote"
? "bg-accent-brand text-background"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
{t("remoteSync.originRemote")}
</button>
</div>
</div>
<Dialog
open={step === "picker"}
onOpenChange={(open) => !open && setStep("idle")}
>
<DialogContent className="bg-card border border-border max-w-md">
<DialogHeader>
<DialogTitle className="sr-only">
{t("remoteSync.title")}
</DialogTitle>
</DialogHeader>
<RemoteSyncServerPicker
onServerConfigured={handleServerConfigured}
onCancel={() => setStep("idle")}
/>
</DialogContent>
</Dialog>
<Dialog
open={step === "login"}
onOpenChange={(open) => !open && setStep("idle")}
>
<DialogContent className="bg-card border border-border max-w-4xl h-[80vh] flex flex-col p-0">
<DialogHeader className="p-4 pb-0">
<DialogTitle>
{t("remoteSync.signInTitle", { url: pendingServerUrl })}
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0">
<ElectronLoginForm
serverUrl={pendingServerUrl}
targetPurpose="remoteSync"
onAuthSuccess={handleAuthSuccess}
onChangeServer={() => setStep("picker")}
/>
</div>
</DialogContent>
</Dialog>
</div>
);
}
+259
View File
@@ -0,0 +1,259 @@
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { Label } from "@/components/label.tsx";
import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx";
import { Switch } from "@/components/switch.tsx";
import { useTranslation } from "react-i18next";
import { ChevronDown, X } from "lucide-react";
const SAVED_URLS_KEY = "termix_saved_server_urls";
const MAX_SAVED_URLS = 5;
function getSavedUrls(): string[] {
try {
const raw = localStorage.getItem(SAVED_URLS_KEY);
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
function addSavedUrl(url: string) {
const urls = getSavedUrls().filter((u) => u !== url);
urls.unshift(url);
localStorage.setItem(
SAVED_URLS_KEY,
JSON.stringify(urls.slice(0, MAX_SAVED_URLS)),
);
}
function removeSavedUrl(url: string) {
const urls = getSavedUrls().filter((u) => u !== url);
localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls));
}
interface RemoteSyncServerPickerProps {
onServerConfigured: (serverUrl: string) => void;
onCancel: () => void;
}
export function RemoteSyncServerPicker({
onServerConfigured,
onCancel,
}: RemoteSyncServerPickerProps) {
const { t } = useTranslation();
const [serverUrl, setServerUrl] = useState("");
const [allowInvalidCertificate, setAllowInvalidCertificate] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedUrls, setSavedUrls] = useState<string[]>([]);
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSavedUrls(getSavedUrls());
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [dropdownOpen]);
const handleSaveConfig = async () => {
if (!serverUrl.trim()) {
setError(t("remoteSync.enterServerUrl"));
return;
}
setLoading(true);
setError(null);
try {
const normalizedUrl = serverUrl.trim();
if (
!normalizedUrl.startsWith("http://") &&
!normalizedUrl.startsWith("https://")
) {
setError(t("remoteSync.mustIncludeProtocol"));
setLoading(false);
return;
}
const result = await window.electronAPI?.invoke?.(
"save-remote-sync-config",
{
serverUrl: normalizedUrl,
allowInvalidCertificate:
normalizedUrl.startsWith("https://") && allowInvalidCertificate,
connectedAt: new Date().toISOString(),
},
);
if ((result as { success?: boolean })?.success) {
addSavedUrl(normalizedUrl);
setSavedUrls(getSavedUrls());
onServerConfigured(normalizedUrl);
} else {
setError(t("serverConfig.saveFailed"));
}
} catch {
setError(t("serverConfig.saveError"));
} finally {
setLoading(false);
}
};
const handleUrlChange = (value: string) => {
setServerUrl(value);
setError(null);
};
return (
<div className="flex flex-col gap-5 p-6">
<div className="flex flex-col gap-1">
<p className="font-bold">{t("remoteSync.title")}</p>
<p className="text-sm text-muted-foreground">
{t("remoteSync.description")}
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="remote-sync-server-url">
{t("remoteSync.serverUrl")}
</Label>
<div className="relative" ref={dropdownRef}>
<Input
id="remote-sync-server-url"
type="text"
placeholder="https://your-server.com"
value={serverUrl}
onChange={(e) => handleUrlChange(e.target.value)}
disabled={loading}
className={savedUrls.length > 0 ? "pr-9" : ""}
onFocus={() => {
if (savedUrls.length > 0) setDropdownOpen(true);
}}
/>
{savedUrls.length > 0 && (
<button
type="button"
tabIndex={-1}
onClick={() => setDropdownOpen((o) => !o)}
disabled={loading}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("remoteSync.savedServers")}
>
<ChevronDown className="size-4" />
</button>
)}
{dropdownOpen && savedUrls.length > 0 && (
<div className="absolute z-50 w-full top-full mt-1 border border-border bg-card shadow-md">
<p className="px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border">
{t("remoteSync.savedServers")}
</p>
{savedUrls.map((url) => (
<div
key={url}
className="flex items-center justify-between group hover:bg-muted transition-colors"
>
<button
type="button"
className="flex-1 text-left px-2.5 py-2 text-sm font-mono truncate"
onClick={() => {
handleUrlChange(url);
setDropdownOpen(false);
}}
>
{url}
</button>
<button
type="button"
className="px-2 py-2 text-muted-foreground hover:text-destructive transition-colors shrink-0"
title={t("remoteSync.removeServer")}
onClick={(e) => {
e.stopPropagation();
removeSavedUrl(url);
const updated = getSavedUrls();
setSavedUrls(updated);
if (updated.length === 0) setDropdownOpen(false);
}}
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
)}
</div>
</div>
{serverUrl.trim().startsWith("https://") && (
<div className="flex items-start justify-between gap-3 border border-border bg-muted/20 p-3">
<div className="flex flex-col gap-1">
<Label htmlFor="remote-sync-allow-invalid-certificate">
{t("remoteSync.allowInvalidCertificate")}
</Label>
<p className="text-xs text-muted-foreground">
{t("remoteSync.allowInvalidCertificateDesc")}
</p>
</div>
<Switch
id="remote-sync-allow-invalid-certificate"
checked={allowInvalidCertificate}
onCheckedChange={setAllowInvalidCertificate}
disabled={loading}
/>
</div>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={onCancel}
disabled={loading}
>
{t("remoteSync.cancelButton")}
</Button>
<Button
type="button"
className="flex-1 bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
onClick={handleSaveConfig}
disabled={loading || !serverUrl.trim()}
>
{loading ? (
<span className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-background border-t-transparent rounded-full animate-spin" />
{t("serverConfig.saving")}
</span>
) : (
t("remoteSync.continueButton")
)}
</Button>
</div>
</div>
</div>
);
}
+1
View File
@@ -124,6 +124,7 @@ function hostToSSHHost(h: Host): SSHHost {
defaultPath: h.defaultPath ?? "", defaultPath: h.defaultPath ?? "",
tunnelConnections: [], tunnelConnections: [],
connectionType: "ssh", connectionType: "ssh",
connectionOrigin: h.connectionOrigin ?? null,
createdAt: "", createdAt: "",
updatedAt: "", updatedAt: "",
} as SSHHost; } as SSHHost;
+2 -3
View File
@@ -33,7 +33,6 @@ import {
getCommandHistoryEnabled, getCommandHistoryEnabled,
updateCommandHistoryEnabled, updateCommandHistoryEnabled,
isElectron, isElectron,
getConfiguredServerUrl,
getUserRoles, getUserRoles,
} from "@/main-axios"; } from "@/main-axios";
import { import {
@@ -808,7 +807,7 @@ export function AdminSettingsPanel({
try { try {
const apiUrl = getDatabaseTransferUrl("export", { const apiUrl = getDatabaseTransferUrl("export", {
electron: isElectron(), electron: isElectron(),
configuredServerUrl: getConfiguredServerUrl(), configuredServerUrl: null,
location: window.location, location: window.location,
}); });
@@ -854,7 +853,7 @@ export function AdminSettingsPanel({
try { try {
const apiUrl = getDatabaseTransferUrl("import", { const apiUrl = getDatabaseTransferUrl("import", {
electron: isElectron(), electron: isElectron(),
configuredServerUrl: getConfiguredServerUrl(), configuredServerUrl: null,
location: window.location, location: window.location,
}); });
+38 -1
View File
@@ -20,6 +20,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types"; import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
import { getAlertFirings } from "@/api/alerts-api"; import { getAlertFirings } from "@/api/alerts-api";
import { isElectron } from "@/lib/electron";
export type RailView = export type RailView =
| "hosts" | "hosts"
@@ -247,8 +248,44 @@ export function AppRail({
return () => window.removeEventListener("hiddenRailTabsChanged", handler); return () => window.removeEventListener("hiddenRailTabsChanged", handler);
}, []); }, []);
// Termix ID publishes SSH public keys under a claimed public handle for
// other servers to fetch -- meaningless for a standalone desktop install
// with no synced multi-device account, so it stays hidden until a remote
// server is actually connected.
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState(
() => !isElectron(),
);
useEffect(() => {
if (!isElectron()) return;
let cancelled = false;
const refreshSyncStatus = () => {
window.electronAPI
?.invoke?.("get-remote-sync-config")
.then((config) => {
if (!cancelled) {
setIsRemoteSyncConnected(
!!(config as { serverUrl?: string } | null)?.serverUrl,
);
}
})
.catch(() => {});
};
refreshSyncStatus();
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() =>
refreshSyncStatus(),
);
return () => {
cancelled = true;
unsubscribe?.();
};
}, []);
const railExpanded = pinned || (expandOnHover && hovered); const railExpanded = pinned || (expandOnHover && hovered);
const railButtons = buildRailButtons(splitMode, t, hiddenTabs); const effectiveHiddenTabs = isRemoteSyncConnected
? hiddenTabs
: new Set([...hiddenTabs, "termix-id"]);
const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs);
return ( return (
<div <div
+5
View File
@@ -97,6 +97,10 @@ export function createHostEditorForm(
? "chain" ? "chain"
: "single") as "single" | "chain", : "single") as "single" | "chain",
socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[], socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[],
connectionOrigin: (host?.connectionOrigin ?? null) as
| "local"
| "remote"
| null,
enableTerminal: host?.enableTerminal ?? true, enableTerminal: host?.enableTerminal ?? true,
enableSessionLogging: enableSessionLogging:
host?.enableSessionLogging ?? d?.enableSessionLogging ?? true, host?.enableSessionLogging ?? d?.enableSessionLogging ?? true,
@@ -322,6 +326,7 @@ export function buildHostEditorPayload(
form.socks5ProxyMode === "single" ? form.socks5Password || null : null, form.socks5ProxyMode === "single" ? form.socks5Password || null : null,
socks5ProxyChain: socks5ProxyChain:
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null, form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
connectionOrigin: form.connectionOrigin,
enableSsh: protocols.enableSsh, enableSsh: protocols.enableSsh,
enableRdp: protocols.enableRdp, enableRdp: protocols.enableRdp,
enableVnc: protocols.enableVnc, enableVnc: protocols.enableVnc,
+26 -1
View File
@@ -16,7 +16,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { FolderPathPicker } from "./FolderPathPicker"; import { FolderPathPicker } from "./FolderPathPicker";
import { getSSHFolders } from "@/main-axios"; import { getSSHFolders, isElectron } from "@/main-axios";
import type { HostEditorForm, HostProtocols } from "./HostEditorData"; import type { HostEditorForm, HostProtocols } from "./HostEditorData";
type HostEditorSetField = <K extends keyof HostEditorForm>( type HostEditorSetField = <K extends keyof HostEditorForm>(
@@ -717,6 +717,31 @@ export function HostEditorGeneralTab({
) : null} ) : null}
</div> </div>
)} )}
{isElectron() && protocols.enableSsh && (
<SettingRow
label={t("hosts.connectionOrigin")}
description={t("hosts.connectionOriginDesc")}
>
<select
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
value={form.connectionOrigin ?? ""}
onChange={(e) =>
setField(
"connectionOrigin",
(e.target.value || null) as "local" | "remote" | null,
)
}
>
<option value="">{t("hosts.connectionOriginDefault")}</option>
<option value="local">
{t("hosts.connectionOriginLocal")}
</option>
<option value="remote">
{t("hosts.connectionOriginRemote")}
</option>
</select>
</SettingRow>
)}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> <span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
+2 -6
View File
@@ -225,9 +225,7 @@ export function HostEditorRdpTab({
} }
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
> >
<option value=""> <option value="">{t("hosts.guac.selectCredential")}</option>
{t("hosts.guac.selectCredential")}
</option>
{credentials.map((c) => ( {credentials.map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name} {c.username ? `${c.name} (${c.username})` : c.name}
@@ -255,9 +253,7 @@ export function HostEditorRdpTab({
className="h-8 text-xs pr-8" className="h-8 text-xs pr-8"
placeholder="••••••••" placeholder="••••••••"
value={form.rdpPassword} value={form.rdpPassword}
onChange={(e) => onChange={(e) => setField("rdpPassword", e.target.value)}
setField("rdpPassword", e.target.value)
}
/> />
</div> </div>
</> </>
+2 -22
View File
@@ -32,31 +32,11 @@ export function SerialPanel({ onConnect }: SerialPanelProps) {
const [loadingPorts, setLoadingPorts] = useState(false); const [loadingPorts, setLoadingPorts] = useState(false);
const buildWsUrl = () => { const buildWsUrl = () => {
const isDev = // Serial is always local -- the device is physically attached to this
process.env.NODE_ENV === "development" && // desktop machine, so it never routes through a remote server.
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
if (
isDev ||
(isElectron() &&
!(window as { configuredServerUrl?: string }).configuredServerUrl)
) {
const token = localStorage.getItem("jwt"); const token = localStorage.getItem("jwt");
const base = "ws://127.0.0.1:30011"; const base = "ws://127.0.0.1:30011";
return token ? `${base}?token=${encodeURIComponent(token)}` : base; return token ? `${base}?token=${encodeURIComponent(token)}` : base;
}
const configuredUrl = (window as { configuredServerUrl?: string })
.configuredServerUrl;
if (!configuredUrl) return null;
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
const token = localStorage.getItem("jwt");
const base = `${wsProtocol}${wsHost}/serial/websocket/`;
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
}; };
const refreshPorts = useCallback(() => { const refreshPorts = useCallback(() => {
+51 -29
View File
@@ -16,7 +16,6 @@ import {
getUserRoles, getUserRoles,
saveUserPreferences, saveUserPreferences,
getUserPreferences, getUserPreferences,
getConfiguredServerUrl,
} from "@/main-axios"; } from "@/main-axios";
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url"; import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
import { import {
@@ -29,6 +28,7 @@ import {
import type { UserRole } from "@/main-axios"; import type { UserRole } from "@/main-axios";
import type React from "react"; import type React from "react";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { RemoteSyncPanel } from "@/settings/RemoteSyncPanel.tsx";
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager"; import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
@@ -426,13 +426,11 @@ function PasswordChangeSection({
export function UserProfilePanel({ export function UserProfilePanel({
username, username,
onLogout, onLogout,
onChangeServer,
userPrefs, userPrefs,
onPrefsChange, onPrefsChange,
}: { }: {
username?: string; username?: string;
onLogout?: () => void; onLogout?: () => void;
onChangeServer?: () => void;
userPrefs?: { userPrefs?: {
reopenTabsOnLogin: boolean; reopenTabsOnLogin: boolean;
storageMode?: string | null; storageMode?: string | null;
@@ -541,6 +539,46 @@ export function UserProfilePanel({
} }
}, [userPrefs?.storageMode]); }, [userPrefs?.storageMode]);
// Remote sync is not connected by default on the desktop app (it's an
// opt-in feature configured from this same panel). "cloud" storage mode
// and Termix ID both assume a real multi-device server account, so they
// stay hidden/forced-off until the user actually connects one.
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState(
() => !isElectron(),
);
useEffect(() => {
if (!isElectron()) return;
let cancelled = false;
const refreshSyncStatus = () => {
window.electronAPI
?.invoke?.("get-remote-sync-config")
.then((config) => {
if (!cancelled) {
setIsRemoteSyncConnected(
!!(config as { serverUrl?: string } | null)?.serverUrl,
);
}
})
.catch(() => {});
};
refreshSyncStatus();
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() =>
refreshSyncStatus(),
);
return () => {
cancelled = true;
unsubscribe?.();
};
}, []);
useEffect(() => {
if (isElectron() && !isRemoteSyncConnected && storageMode === "cloud") {
setStorageMode("local");
onPrefsChange?.({ storageMode: "local" });
}
}, [isRemoteSyncConnected, storageMode, onPrefsChange]);
// Settings toggles — all backed by localStorage // Settings toggles — all backed by localStorage
const [commandAutocomplete, setCommandAutocomplete] = useState( const [commandAutocomplete, setCommandAutocomplete] = useState(
() => localStorage.getItem("commandAutocomplete") === "true", () => localStorage.getItem("commandAutocomplete") === "true",
@@ -1143,7 +1181,7 @@ export function UserProfilePanel({
try { try {
const apiUrl = getDatabaseTransferUrl("export", { const apiUrl = getDatabaseTransferUrl("export", {
electron: isElectron(), electron: isElectron(),
configuredServerUrl: getConfiguredServerUrl(), configuredServerUrl: null,
location: window.location, location: window.location,
}); });
@@ -1171,9 +1209,7 @@ export function UserProfilePanel({
toast.success(t("newUi.sidebar.userProfile.exportSuccess")); toast.success(t("newUi.sidebar.userProfile.exportSuccess"));
} else { } else {
const err = await response.json().catch(() => ({})); const err = await response.json().catch(() => ({}));
toast.error( toast.error(err.error || t("newUi.sidebar.userProfile.exportFailed"));
err.error || t("newUi.sidebar.userProfile.exportFailed"),
);
} }
} catch { } catch {
toast.error(t("newUi.sidebar.userProfile.exportFailed")); toast.error(t("newUi.sidebar.userProfile.exportFailed"));
@@ -1191,7 +1227,7 @@ export function UserProfilePanel({
try { try {
const apiUrl = getDatabaseTransferUrl("import", { const apiUrl = getDatabaseTransferUrl("import", {
electron: isElectron(), electron: isElectron(),
configuredServerUrl: getConfiguredServerUrl(), configuredServerUrl: null,
location: window.location, location: window.location,
}); });
@@ -1280,7 +1316,10 @@ export function UserProfilePanel({
</a> </a>
</div> </div>
{/* Storage mode toggle */} {/* Storage mode toggle only meaningful once a remote server is
connected; with no sync there's nowhere for "cloud" to sync to,
so this stays forced to local storage and hidden. */}
{(!isElectron() || isRemoteSyncConnected) && (
<div className="border border-border bg-card px-3 py-2.5 flex flex-col gap-2"> <div className="border border-border bg-card px-3 py-2.5 flex flex-col gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> <span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.storageModeSwitch")} {t("newUi.sidebar.userProfile.storageModeSwitch")}
@@ -1318,6 +1357,7 @@ export function UserProfilePanel({
{t("newUi.sidebar.userProfile.resetToDefaults")} {t("newUi.sidebar.userProfile.resetToDefaults")}
</button> </button>
</div> </div>
)}
{/* Account */} {/* Account */}
<AccordionSection <AccordionSection
@@ -1439,27 +1479,9 @@ export function UserProfilePanel({
</div> </div>
</div> </div>
{isElectron() && onChangeServer && ( {isElectron() && (
<div className="border-t border-border pt-3 mt-3"> <div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between"> <RemoteSyncPanel />
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("serverConfig.changeServer")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.changeServerDescription")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 ml-3 text-[10px] h-7"
onClick={onChangeServer}
>
<Server className="size-3" />
{t("serverConfig.changeServer")}
</Button>
</div>
</div> </div>
)} )}
@@ -0,0 +1,88 @@
import { describe, it, expect, afterEach } from "vitest";
import { resolveConnectionOrigin } from "../../lib/connection-origin.js";
const win = window as unknown as Record<string, unknown>;
afterEach(() => {
delete win.IS_ELECTRON;
delete win.electronAPI;
});
describe("resolveConnectionOrigin", () => {
it("always resolves rdp/vnc/telnet to remote, even with a local override", async () => {
win.IS_ELECTRON = true;
for (const connectionType of ["rdp", "vnc", "telnet"]) {
await expect(
resolveConnectionOrigin({ connectionType, connectionOrigin: "local" }),
).resolves.toBe("remote");
}
});
it("always resolves serial to local, even with a remote override", async () => {
win.IS_ELECTRON = true;
await expect(
resolveConnectionOrigin({
connectionType: "serial",
connectionOrigin: "remote",
}),
).resolves.toBe("local");
});
it("resolves to local outside Electron regardless of connectionType", async () => {
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: "remote",
}),
).resolves.toBe("local");
});
it("honors a host-level override for ssh in Electron", async () => {
win.IS_ELECTRON = true;
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: "remote",
}),
).resolves.toBe("remote");
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: "local",
}),
).resolves.toBe("local");
});
it("falls back to the desktop-wide default when no host override is set", async () => {
win.IS_ELECTRON = true;
win.electronAPI = {
invoke: async (channel: string) => {
if (channel === "get-desktop-settings") {
return { defaultConnectionOrigin: "remote" };
}
return null;
},
};
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: null,
}),
).resolves.toBe("remote");
});
it("defaults to local when the desktop settings lookup fails", async () => {
win.IS_ELECTRON = true;
win.electronAPI = {
invoke: async () => {
throw new Error("ipc failed");
},
};
await expect(
resolveConnectionOrigin({
connectionType: "ssh",
connectionOrigin: null,
}),
).resolves.toBe("local");
});
});