mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
feat: rework Electron desktop app to run standalone-first with optional two-way sync to a remote Termix server
This commit is contained in:
@@ -226,6 +226,15 @@ http {
|
||||
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(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -215,6 +215,15 @@ http {
|
||||
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(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
+67
-1
@@ -20,6 +20,7 @@ const net = require("net");
|
||||
const { URL } = require("url");
|
||||
const { fork, spawn } = require("child_process");
|
||||
const WebSocket = require("ws");
|
||||
const remoteSync = require("./remote-sync.cjs");
|
||||
|
||||
// Portable mode: if a `.portable` marker exists next to the executable,
|
||||
// store all data in a `data` folder beside the exe instead of %APPDATA%.
|
||||
@@ -852,6 +853,7 @@ function startBackendServer() {
|
||||
NODE_ENV: "production",
|
||||
ELECTRON_EMBEDDED: "true",
|
||||
PORT: "30001",
|
||||
VERSION: app.getVersion(),
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
||||
});
|
||||
@@ -1335,7 +1337,6 @@ ipcMain.handle("get-embedded-server-status", () => {
|
||||
return {
|
||||
running:
|
||||
backendProcess !== null && !backendProcess.killed && !backendStartFailed,
|
||||
embedded: !isDev,
|
||||
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() {
|
||||
return path.join(app.getPath("userData"), "c2s-tunnels.json");
|
||||
}
|
||||
@@ -2974,6 +3039,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
createTray();
|
||||
createWindow();
|
||||
remoteSync.initRemoteSync(() => mainWindow);
|
||||
logToFile("=== Startup complete ===");
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
startC2SAutoStartTunnels: () =>
|
||||
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"),
|
||||
getSessionCookie: (name, targetUrl) =>
|
||||
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
|
||||
|
||||
@@ -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
@@ -39,6 +39,19 @@ const nanHeaderPatched = patchFile(path.join(nanDir, "nan.h"), [
|
||||
# define __builtin_frame_address(level) _AddressOfReturnAddress()
|
||||
#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`,
|
||||
},
|
||||
]);
|
||||
@@ -63,23 +76,24 @@ const bindingPatched = patchFile(bindingPath, [
|
||||
},
|
||||
]);
|
||||
|
||||
// 2. nan_implementation_12_inl.h: replace v8::External::New() with the 3-arg form.
|
||||
// Electron 42 / V8 13+ requires an ExternalPointerTypeTag as the third argument.
|
||||
// 2. nan_implementation_12_inl.h: replace v8::External::New() with a form that
|
||||
// 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");
|
||||
let implPatched = false;
|
||||
if (fs.existsSync(implPath)) {
|
||||
let src = fs.readFileSync(implPath, "utf8");
|
||||
const before = src;
|
||||
|
||||
const TAG = "static_cast<v8::ExternalPointerTypeTag>(0)";
|
||||
if (!src.includes(TAG)) {
|
||||
if (!src.includes("NAN_EXTERNAL_TAG_ARG")) {
|
||||
src = src.replace(
|
||||
/v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value\)/g,
|
||||
`v8::External::New(v8::Isolate::GetCurrent(), value, ${TAG})`,
|
||||
/v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value(?:,\s*static_cast<v8::ExternalPointerTypeTag>\(0\))?\)/g,
|
||||
`v8::External::New(v8::Isolate::GetCurrent(), value NAN_EXTERNAL_TAG_ARG)`,
|
||||
);
|
||||
src = src.replace(
|
||||
/v8::External::New\(isolate,\s*reinterpret_cast<void \*>\(callback\)\)/g,
|
||||
`v8::External::New(isolate, reinterpret_cast<void *>(callback), ${TAG})`,
|
||||
/v8::External::New\(isolate,\s*reinterpret_cast<void \*>\(callback\)(?:,\s*static_cast<v8::ExternalPointerTypeTag>\(0\))?\)/g,
|
||||
`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.
|
||||
// The new API requires an ExternalPointerTypeTag argument.
|
||||
// 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(NAN_EXTERNAL_TAG_PARAM)
|
||||
// on v8::External, same conditional-tag reasoning as above.
|
||||
const callbacksPath = path.join(nanDir, "nan_callbacks_12_inl.h");
|
||||
let callbacksPatched = false;
|
||||
if (fs.existsSync(callbacksPath)) {
|
||||
let src = fs.readFileSync(callbacksPath, "utf8");
|
||||
const before = src;
|
||||
|
||||
const TAG = "static_cast<v8::ExternalPointerTypeTag>(0)";
|
||||
if (!src.includes(TAG)) {
|
||||
// Pattern: .As<v8::External>()->Value()) — always followed by ))
|
||||
if (!src.includes("NAN_EXTERNAL_TAG_PARAM")) {
|
||||
// Pattern: .As<v8::External>()->Value()) or ->Value(<old hardcoded tag>))
|
||||
src = src.replace(
|
||||
/\.As<v8::External>\(\)->Value\(\)\)/g,
|
||||
`.As<v8::External>()->Value(${TAG}))`,
|
||||
/\.As<v8::External>\(\)->Value\((?:static_cast<v8::ExternalPointerTypeTag>\(0\))?\)\)/g,
|
||||
`.As<v8::External>()->Value(NAN_EXTERNAL_TAG_PARAM))`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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;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)}",
|
||||
],
|
||||
],
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
|
||||
import vaultRoutes from "./routes/vault.js";
|
||||
import alertRulesRoutes from "./routes/alert-rules-routes.js";
|
||||
import syncRoutes from "./routes/sync.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
@@ -1749,6 +1750,7 @@ registerAuditLogRoutes(app, authenticateJWT);
|
||||
registerTailscaleRoutes(app, authenticateJWT);
|
||||
app.use("/vault", vaultRoutes);
|
||||
app.use("/", alertRulesRoutes);
|
||||
app.use("/sync", syncRoutes);
|
||||
|
||||
const frontendDistPaths = [
|
||||
path.join(__dirname, "../../../dist"),
|
||||
|
||||
@@ -726,12 +726,16 @@ const addColumnIfNotExists = (
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN "${column}" ${definition};`);
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
error: alterError,
|
||||
});
|
||||
const message =
|
||||
alterError instanceof Error ? alterError.message : String(alterError);
|
||||
databaseLogger.warn(
|
||||
`Failed to add column ${column} to ${table}: ${message}`,
|
||||
{
|
||||
operation: "schema_migration",
|
||||
table,
|
||||
column,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1489,6 +1493,7 @@ const migrateSchema = () => {
|
||||
{ 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: "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) {
|
||||
@@ -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", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
|
||||
@@ -240,6 +240,12 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
socks5Password: text("socks5_password"),
|
||||
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"),
|
||||
wolBroadcastAddress: text("wol_broadcast_address"),
|
||||
portKnockSequence: text("port_knock_sequence"),
|
||||
@@ -251,6 +257,11 @@ export const hosts = sqliteTable("ssh_data", {
|
||||
hostKeyLastVerified: text("host_key_last_verified"),
|
||||
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")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -357,6 +368,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
|
||||
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -391,6 +403,7 @@ export const snippets = sqliteTable("snippets", {
|
||||
description: text("description"),
|
||||
folder: text("folder"),
|
||||
order: integer("order").notNull().default(0),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -408,6 +421,7 @@ export const snippetFolders = sqliteTable("snippet_folders", {
|
||||
name: text("name").notNull(),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -468,6 +482,7 @@ export const sshFolders = sqliteTable("ssh_folders", {
|
||||
credentialId: integer("credential_id").references(() => sshCredentials.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -786,6 +801,7 @@ export const vaultProfiles = sqliteTable("vault_profiles", {
|
||||
keyType: text("key_type"),
|
||||
// When true the profile is visible/usable by all users on the server
|
||||
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -942,9 +958,13 @@ export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
|
||||
label: text("label").notNull(),
|
||||
url: text("url").notNull(),
|
||||
order: integer("order").notNull().default(0),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// --- termix-id begin ---
|
||||
@@ -1130,6 +1150,7 @@ export const homepageItems = sqliteTable("homepage_items", {
|
||||
title: text("title"),
|
||||
config: text("config").notNull().default("{}"),
|
||||
folderId: integer("folder_id"),
|
||||
syncId: text("sync_id").unique(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -1151,3 +1172,20 @@ export const homepageLayouts = sqliteTable("homepage_layouts", {
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
// --- 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 { randomUUID } from "crypto";
|
||||
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
@@ -18,7 +19,7 @@ export class CredentialRepository {
|
||||
async create(credential: NewCredentialRecord): Promise<CredentialRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(sshCredentials)
|
||||
.values(credential)
|
||||
.values({ syncId: randomUUID(), ...credential })
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
@@ -30,7 +31,11 @@ export class CredentialRepository {
|
||||
): Promise<CredentialRecord> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const tempId = credential.id ?? Date.now();
|
||||
const dataWithTempId = { ...credential, id: tempId };
|
||||
const dataWithTempId = {
|
||||
syncId: randomUUID(),
|
||||
...credential,
|
||||
id: tempId,
|
||||
};
|
||||
const encryptedCredential = this.encryptCredentialRecordForWrite(
|
||||
dataWithTempId,
|
||||
userId,
|
||||
@@ -203,7 +208,10 @@ export class CredentialRepository {
|
||||
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
|
||||
.delete(sshCredentials)
|
||||
.where(
|
||||
@@ -212,10 +220,10 @@ export class CredentialRepository {
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: sshCredentials.id });
|
||||
.returning({ syncId: sshCredentials.syncId });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length > 0;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { dashboardServiceLinks } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
@@ -40,11 +41,13 @@ export class DashboardServiceLinkRepository {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(dashboardServiceLinks)
|
||||
.values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
label: input.label,
|
||||
url: input.url,
|
||||
order: nextOrder,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
})
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
@@ -76,7 +79,7 @@ export class DashboardServiceLinkRepository {
|
||||
): Promise<DashboardServiceLinkRecord | null> {
|
||||
const [updated] = await this.context.drizzle
|
||||
.update(dashboardServiceLinks)
|
||||
.set(updates)
|
||||
.set({ ...updates, updatedAt: new Date().toISOString() })
|
||||
.where(
|
||||
and(
|
||||
eq(dashboardServiceLinks.id, id),
|
||||
@@ -92,7 +95,10 @@ export class DashboardServiceLinkRepository {
|
||||
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
|
||||
.delete(dashboardServiceLinks)
|
||||
.where(
|
||||
@@ -101,13 +107,11 @@ export class DashboardServiceLinkRepository {
|
||||
eq(dashboardServiceLinks.userId, userId),
|
||||
),
|
||||
)
|
||||
.returning({ id: dashboardServiceLinks.id });
|
||||
.returning({ syncId: dashboardServiceLinks.syncId });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
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 { SnippetRepository } from "./snippet-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 { TermixIdentityCaRepository } from "./termix-identity-ca-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 {
|
||||
return new DismissedAlertRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { homepageItems } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
@@ -37,6 +38,7 @@ export class HomepageItemRepository {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(homepageItems)
|
||||
.values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
typeId: input.typeId,
|
||||
title: input.title,
|
||||
@@ -82,17 +84,18 @@ export class HomepageItemRepository {
|
||||
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
|
||||
.delete(homepageItems)
|
||||
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
|
||||
.returning({ id: homepageItems.id });
|
||||
.returning({ syncId: homepageItems.syncId });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, eq, like, or, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
|
||||
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
@@ -96,6 +97,7 @@ export class HostFolderRepository {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(sshFolders)
|
||||
.values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name,
|
||||
color,
|
||||
@@ -126,7 +128,7 @@ export class HostFolderRepository {
|
||||
async deleteHostsAndFolderRecords(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
): Promise<void> {
|
||||
): Promise<{ hostSyncIds: string[]; folderSyncIds: string[] }> {
|
||||
const folderMatch = (col: SQLiteColumn) =>
|
||||
or(eq(col, folderName), like(col, `${folderName} / %`));
|
||||
|
||||
@@ -137,11 +139,21 @@ export class HostFolderRepository {
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||
}
|
||||
|
||||
await this.context.drizzle
|
||||
const deletedFolders = await this.context.drizzle
|
||||
.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();
|
||||
|
||||
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> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { hostAccess, hosts } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
@@ -22,7 +23,7 @@ export class HostRepository {
|
||||
async create(host: NewHostRecord): Promise<HostRecord> {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(hosts)
|
||||
.values(host)
|
||||
.values({ syncId: randomUUID(), ...host })
|
||||
.returning();
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
@@ -34,7 +35,11 @@ export class HostRepository {
|
||||
): Promise<HostRecord> {
|
||||
const userDataKey = DataCrypto.validateUserAccess(userId);
|
||||
const tempId = host.id ?? Date.now();
|
||||
const dataWithTempId = { ...host, id: tempId };
|
||||
const dataWithTempId = {
|
||||
syncId: randomUUID(),
|
||||
...host,
|
||||
id: tempId,
|
||||
};
|
||||
const encryptedHost = DataCrypto.encryptRecord(
|
||||
"ssh_data",
|
||||
dataWithTempId,
|
||||
@@ -221,16 +226,19 @@ export class HostRepository {
|
||||
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);
|
||||
|
||||
const rows = await this.context.drizzle
|
||||
.delete(hosts)
|
||||
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
||||
.returning({ id: hosts.id });
|
||||
.returning({ syncId: hosts.syncId });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows.length > 0;
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
import { snippetFolders, snippets } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
@@ -151,6 +152,7 @@ export class SnippetRepository {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(snippets)
|
||||
.values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name: input.name.trim(),
|
||||
content: input.content.trim(),
|
||||
@@ -343,6 +345,7 @@ export class SnippetRepository {
|
||||
|
||||
const maxOrder = await this.maxOrderForFolder(userId, folderVal);
|
||||
await this.context.drizzle.insert(snippets).values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name: snippet.name.trim(),
|
||||
content: snippet.content.trim(),
|
||||
@@ -377,6 +380,7 @@ export class SnippetRepository {
|
||||
const rows = await this.context.drizzle
|
||||
.insert(snippetFolders)
|
||||
.values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name: name.trim(),
|
||||
color: color?.trim() || null,
|
||||
@@ -452,19 +456,24 @@ export class SnippetRepository {
|
||||
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
|
||||
.update(snippets)
|
||||
.set({ folder: null })
|
||||
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
|
||||
|
||||
await this.context.drizzle
|
||||
const rows = await this.context.drizzle
|
||||
.delete(snippetFolders)
|
||||
.where(
|
||||
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
|
||||
);
|
||||
)
|
||||
.returning({ syncId: snippetFolders.syncId });
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
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 { randomUUID } from "crypto";
|
||||
import { vaultProfiles } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
@@ -47,6 +48,7 @@ export class VaultProfileRepository {
|
||||
const [created] = await this.context.drizzle
|
||||
.insert(vaultProfiles)
|
||||
.values({
|
||||
syncId: randomUUID(),
|
||||
userId: input.userId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
@@ -98,17 +100,15 @@ export class VaultProfileRepository {
|
||||
return updated ?? null;
|
||||
}
|
||||
|
||||
async deleteById(id: number): Promise<boolean> {
|
||||
async deleteById(id: number): Promise<{ syncId: string | null } | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.delete(vaultProfiles)
|
||||
.where(eq(vaultProfiles.id, id))
|
||||
.returning({ id: vaultProfiles.id });
|
||||
.returning({ syncId: vaultProfiles.syncId });
|
||||
|
||||
if (rows.length > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rows.length > 0;
|
||||
if (rows.length === 0) return null;
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<number> {
|
||||
|
||||
@@ -460,9 +460,9 @@ export function registerAcmeSSLRoutes(
|
||||
!certificate.includes("BEGIN CERTIFICATE") ||
|
||||
!privateKey.includes("PRIVATE KEY")
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "A valid PEM certificate and private key are required" });
|
||||
return res.status(400).json({
|
||||
error: "A valid PEM certificate and private key are required",
|
||||
});
|
||||
}
|
||||
|
||||
await fs.mkdir(SSL_DIR, { recursive: true });
|
||||
@@ -485,7 +485,8 @@ export function registerAcmeSSLRoutes(
|
||||
);
|
||||
} catch {
|
||||
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,
|
||||
createCurrentHostRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -642,6 +643,13 @@ router.delete(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
if (credentialToDelete.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"sshCredentials",
|
||||
credentialToDelete.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
// Shares stay in place; re-snapshot so recipients fall back to whatever
|
||||
// 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 { isNonEmptyString } from "./host-normalizers.js";
|
||||
import express from "express";
|
||||
import { createCurrentDashboardServiceLinkRepository } from "../repositories/factory.js";
|
||||
import {
|
||||
createCurrentDashboardServiceLinkRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
export const dashboardServiceLinksRouter = express.Router();
|
||||
|
||||
@@ -152,10 +155,18 @@ dashboardServiceLinksRouter.delete(
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
await createCurrentDashboardServiceLinkRepository().deleteForUser(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
const deleted =
|
||||
await createCurrentDashboardServiceLinkRepository().deleteForUser(
|
||||
userId,
|
||||
id,
|
||||
);
|
||||
if (deleted?.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"dashboardServiceLinks",
|
||||
deleted.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
|
||||
res.json({ message: "Service link deleted" });
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { homepageLogger } from "../../utils/logger.js";
|
||||
import { createCurrentHomepageItemRepository } from "../repositories/factory.js";
|
||||
import {
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import express from "express";
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
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" });
|
||||
} catch (err) {
|
||||
homepageLogger.error("Failed to delete homepage item", err);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentSessionRecordingRepository,
|
||||
createCurrentTransferRecentRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
|
||||
@@ -318,9 +319,17 @@ export function registerHostFolderRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
await hostFolderRepository.deleteHostsAndFolderRecords(
|
||||
const { hostSyncIds, folderSyncIds } =
|
||||
await hostFolderRepository.deleteHostsAndFolderRecords(
|
||||
userId,
|
||||
folderName,
|
||||
);
|
||||
const tombstoneRepository = createCurrentSyncTombstoneRepository();
|
||||
await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds);
|
||||
await tombstoneRepository.recordMany(
|
||||
userId,
|
||||
folderName,
|
||||
"sshFolders",
|
||||
folderSyncIds,
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import {
|
||||
isNonEmptyString,
|
||||
@@ -201,6 +202,7 @@ router.post(
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain,
|
||||
connectionOrigin,
|
||||
portKnockSequence,
|
||||
overrideCredentialUsername,
|
||||
macAddress,
|
||||
@@ -331,6 +333,10 @@ router.post(
|
||||
socks5ProxyChain: socks5ProxyChain
|
||||
? JSON.stringify(socks5ProxyChain)
|
||||
: null,
|
||||
connectionOrigin:
|
||||
connectionOrigin === "local" || connectionOrigin === "remote"
|
||||
? connectionOrigin
|
||||
: null,
|
||||
macAddress: macAddress || null,
|
||||
wolBroadcastAddress: wolBroadcastAddress || null,
|
||||
portKnockSequence: portKnockSequence
|
||||
@@ -843,6 +849,7 @@ router.put(
|
||||
socks5Username,
|
||||
socks5Password,
|
||||
socks5ProxyChain,
|
||||
connectionOrigin,
|
||||
portKnockSequence,
|
||||
overrideCredentialUsername,
|
||||
macAddress,
|
||||
@@ -970,6 +977,10 @@ router.put(
|
||||
socks5ProxyChain: socks5ProxyChain
|
||||
? JSON.stringify(socks5ProxyChain)
|
||||
: null,
|
||||
connectionOrigin:
|
||||
connectionOrigin === "local" || connectionOrigin === "remote"
|
||||
? connectionOrigin
|
||||
: null,
|
||||
macAddress: macAddress || null,
|
||||
wolBroadcastAddress: wolBroadcastAddress || null,
|
||||
portKnockSequence: portKnockSequence
|
||||
@@ -2054,6 +2065,13 @@ router.delete(
|
||||
);
|
||||
|
||||
await createCurrentHostRepository().deleteForUser(userId, numericHostId);
|
||||
if (hostToDelete.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"hosts",
|
||||
hostToDelete.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
databaseLogger.success("SSH host deleted", {
|
||||
operation: "host_delete_success",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -400,7 +401,17 @@ router.delete(
|
||||
try {
|
||||
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(
|
||||
`Snippet folder deleted: ${folderName} by user ${userId}`,
|
||||
@@ -1241,6 +1252,14 @@ router.delete(
|
||||
return res.status(404).json({ error: "Snippet not found" });
|
||||
}
|
||||
|
||||
if (existing.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"snippets",
|
||||
existing.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
databaseLogger.info("Command snippet deleted", {
|
||||
operation: "snippet_delete",
|
||||
userId,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
* /users/count:
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Request, Response } from "express";
|
||||
import {
|
||||
createCurrentVaultProfileRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
@@ -421,7 +422,14 @@ router.delete(
|
||||
.status(403)
|
||||
.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 });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete vault profile", err);
|
||||
|
||||
@@ -15,6 +15,60 @@ import {
|
||||
setGlobalLogLevel,
|
||||
} 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 () => {
|
||||
const initStartTime = Date.now();
|
||||
try {
|
||||
@@ -107,6 +161,10 @@ import {
|
||||
await import("./utils/crypto-migration/shared-host-secrets-migration.js");
|
||||
await runSharedHostSecretsMigration();
|
||||
|
||||
if (process.env.ELECTRON_EMBEDDED === "true") {
|
||||
await provisionLocalDesktopUserIfNeeded();
|
||||
}
|
||||
|
||||
import("./utils/opkssh-binary-manager.js").then(
|
||||
({ OPKSSHBinaryManager }) => {
|
||||
OPKSSHBinaryManager.ensureBinary().catch((error) => {
|
||||
|
||||
@@ -32,7 +32,9 @@ describe("DashboardServiceLinkRepository", () => {
|
||||
label TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
"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)
|
||||
@@ -99,8 +101,10 @@ describe("DashboardServiceLinkRepository", () => {
|
||||
);
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", link.id)).toBe(false);
|
||||
expect(await repo.deleteForUser("user-1", link.id)).toBe(true);
|
||||
expect(await repo.deleteForUser("user-2", link.id)).toBeNull();
|
||||
expect(await repo.deleteForUser("user-1", link.id)).toEqual({
|
||||
syncId: expect.any(String),
|
||||
});
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("HomepageItemRepository", () => {
|
||||
title TEXT,
|
||||
config TEXT NOT NULL DEFAULT '{}',
|
||||
folder_id INTEGER,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -107,8 +108,10 @@ describe("HomepageItemRepository", () => {
|
||||
).toBeNull();
|
||||
expect(writeCount).toBe(2);
|
||||
|
||||
expect(await repo.deleteForUser("user-2", item.id)).toBe(false);
|
||||
expect(await repo.deleteForUser("user-1", item.id)).toBe(true);
|
||||
expect(await repo.deleteForUser("user-2", item.id)).toBeNull();
|
||||
expect(await repo.deleteForUser("user-1", item.id)).toEqual({
|
||||
syncId: expect.any(String),
|
||||
});
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
cert_public_key TEXT,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
sync_id TEXT,
|
||||
created_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
|
||||
@@ -151,6 +152,8 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
host_key_first_seen TEXT,
|
||||
host_key_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
connection_origin TEXT,
|
||||
sync_id TEXT,
|
||||
created_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,
|
||||
@@ -226,9 +229,9 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
expect(
|
||||
await repo.credentials.findByIdForUser("user-2", created.id),
|
||||
).toBeNull();
|
||||
expect(await repo.credentials.deleteForUser("user-1", created.id)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await repo.credentials.deleteForUser("user-1", created.id)).toEqual({
|
||||
syncId: expect.any(String),
|
||||
});
|
||||
expect(
|
||||
await repo.credentials.findByIdForUser("user-1", created.id),
|
||||
).toBeNull();
|
||||
@@ -449,7 +452,9 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
expect(updated?.name).toBe("web-1-renamed");
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -687,6 +692,8 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
.run(host.id, "user-2", "user-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,
|
||||
folder TEXT,
|
||||
auth_type TEXT NOT NULL,
|
||||
sync_id TEXT,
|
||||
created_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_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
connection_origin TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -141,6 +144,7 @@ describe("HostFolderRepository", () => {
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
credential_id INTEGER,
|
||||
sync_id TEXT,
|
||||
created_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_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
connection_origin TEXT,
|
||||
sync_id TEXT,
|
||||
created_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,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -173,6 +176,7 @@ describe("HostResolutionRepository", () => {
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
credential_id INTEGER,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ describe("SnippetRepository", () => {
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
host_filter TEXT
|
||||
@@ -40,6 +41,7 @@ describe("SnippetRepository", () => {
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
sync_id TEXT,
|
||||
created_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_last_verified TEXT,
|
||||
host_key_changed_count INTEGER DEFAULT 0,
|
||||
connection_origin TEXT,
|
||||
sync_id TEXT,
|
||||
created_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,
|
||||
usage_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used TEXT,
|
||||
sync_id TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ describe("VaultProfileRepository", () => {
|
||||
valid_principals TEXT,
|
||||
key_type TEXT,
|
||||
shared INTEGER NOT NULL DEFAULT 0,
|
||||
sync_id TEXT,
|
||||
created_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.deleteById(1)).toBe(true);
|
||||
expect(await repo.deleteById(1)).toBe(false);
|
||||
expect(await repo.deleteById(1)).toEqual({ syncId: null });
|
||||
expect(await repo.deleteById(1)).toBeNull();
|
||||
expect(await repo.findById(1)).toBeNull();
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,9 +11,8 @@ function makeChain(resolveValue: unknown) {
|
||||
for (const m of methods) {
|
||||
chain[m] = vi.fn(() => chain);
|
||||
}
|
||||
(chain as unknown as Promise<unknown>).then = (
|
||||
cb: (v: unknown) => unknown,
|
||||
) => Promise.resolve(resolveValue).then(cb);
|
||||
(chain as unknown as Promise<unknown>).then = (cb: (v: unknown) => unknown) =>
|
||||
Promise.resolve(resolveValue).then(cb);
|
||||
return chain;
|
||||
}
|
||||
|
||||
@@ -72,9 +71,7 @@ describe("analytics", () => {
|
||||
|
||||
it("getOrCreateInstanceId returns the existing id without generating one", async () => {
|
||||
mockGet.mockResolvedValue("existing-id");
|
||||
const { getOrCreateInstanceId } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
|
||||
|
||||
const id = await getOrCreateInstanceId();
|
||||
|
||||
@@ -84,9 +81,7 @@ describe("analytics", () => {
|
||||
|
||||
it("getOrCreateInstanceId generates and persists a new id when absent", async () => {
|
||||
mockGet.mockResolvedValue(null);
|
||||
const { getOrCreateInstanceId } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
|
||||
|
||||
const id = await getOrCreateInstanceId();
|
||||
|
||||
@@ -96,9 +91,8 @@ describe("analytics", () => {
|
||||
|
||||
it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => {
|
||||
delete process.env.POSTHOG_API_KEY;
|
||||
const { collectAndSendHeartbeat } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
const { collectAndSendHeartbeat } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
@@ -108,9 +102,8 @@ describe("analytics", () => {
|
||||
it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => {
|
||||
process.env.POSTHOG_API_KEY = "phc_test";
|
||||
mockGetBoolean.mockResolvedValue(false);
|
||||
const { collectAndSendHeartbeat } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
const { collectAndSendHeartbeat } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
@@ -122,9 +115,8 @@ describe("analytics", () => {
|
||||
mockGetBoolean.mockResolvedValue(true);
|
||||
mockGet.mockResolvedValue("instance-123");
|
||||
mockPost.mockResolvedValue({});
|
||||
const { collectAndSendHeartbeat } = await import(
|
||||
"../../utils/analytics.js"
|
||||
);
|
||||
const { collectAndSendHeartbeat } =
|
||||
await import("../../utils/analytics.js");
|
||||
|
||||
await collectAndSendHeartbeat();
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
|
||||
const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function isAnalyticsEnabled(): Promise<boolean> {
|
||||
return createCurrentSettingsRepository().getBoolean("analytics_enabled", true);
|
||||
return createCurrentSettingsRepository().getBoolean(
|
||||
"analytics_enabled",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getOrCreateInstanceId(): Promise<string> {
|
||||
@@ -120,10 +123,9 @@ export async function collectAndSendHeartbeat(): Promise<void> {
|
||||
|
||||
export function startAnalyticsHeartbeat(): void {
|
||||
if (!process.env.POSTHOG_API_KEY) {
|
||||
analyticsLogger.info(
|
||||
"Analytics disabled: POSTHOG_API_KEY not set",
|
||||
{ operation: "analytics_disabled_no_key" },
|
||||
);
|
||||
analyticsLogger.info("Analytics disabled: POSTHOG_API_KEY not set", {
|
||||
operation: "analytics_disabled_no_key",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+37
-14
@@ -180,6 +180,7 @@ function App() {
|
||||
stored?.loggedIn ? "verifying" : "idle-auth",
|
||||
);
|
||||
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
|
||||
const [verifyRetryCount, setVerifyRetryCount] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// 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
|
||||
@@ -219,11 +220,36 @@ function App() {
|
||||
setPhase("fading-in");
|
||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||
})
|
||||
.catch(() => {
|
||||
clearStoredAuth();
|
||||
setPhase("idle-auth");
|
||||
.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();
|
||||
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) {
|
||||
setAuthUsername(u);
|
||||
@@ -232,6 +258,12 @@ function App() {
|
||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||
if (isElectron()) {
|
||||
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);
|
||||
}
|
||||
|
||||
function handleChangeServer() {
|
||||
localStorage.setItem("termix_show_server_config", "true");
|
||||
handleLogout();
|
||||
}
|
||||
|
||||
const showApp =
|
||||
phase === "idle-app" || phase === "fading-in" || phase === "fading-out";
|
||||
const showAuth =
|
||||
@@ -294,11 +321,7 @@ function App() {
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<AppShell
|
||||
username={authUsername}
|
||||
onLogout={handleLogout}
|
||||
onChangeServer={handleChangeServer}
|
||||
/>
|
||||
<AppShell username={authUsername} onLogout={handleLogout} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Vendored
+9
-1
@@ -64,6 +64,15 @@ export interface ElectronAPI {
|
||||
started: number;
|
||||
errors: string[];
|
||||
}>;
|
||||
onRemoteSyncStatusChanged?: (
|
||||
callback: (status: {
|
||||
connected: boolean;
|
||||
syncing: boolean;
|
||||
lastSyncedAt: string | null;
|
||||
lastError: string | null;
|
||||
needsReauth: boolean;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
clearSessionCookies: () => Promise<void>;
|
||||
getSessionCookie: (
|
||||
name: string,
|
||||
@@ -157,7 +166,6 @@ declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
IS_ELECTRON: boolean;
|
||||
configuredServerUrl?: string | null;
|
||||
electronClipboard?: {
|
||||
writeText(text: string): Promise<boolean>;
|
||||
readText(): Promise<string>;
|
||||
|
||||
@@ -71,6 +71,7 @@ export type Host = {
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: {
|
||||
|
||||
+191
-172
@@ -126,10 +126,12 @@ import {
|
||||
getActiveSessions,
|
||||
getUserPreferences,
|
||||
dismissDonationModal,
|
||||
isElectron,
|
||||
type UserPreferences,
|
||||
type OpenTabRecord,
|
||||
} from "@/main-axios";
|
||||
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
|
||||
import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
|
||||
@@ -193,11 +195,9 @@ export { tabIcon, renderTabContent } from "@/shell/tabUtils";
|
||||
export function AppShell({
|
||||
username,
|
||||
onLogout,
|
||||
onChangeServer,
|
||||
}: {
|
||||
username: string;
|
||||
onLogout: () => void;
|
||||
onChangeServer?: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { setTheme } = useTheme();
|
||||
@@ -238,6 +238,13 @@ export function AppShell({
|
||||
const [hostsLoading, setHostsLoading] = useState(true);
|
||||
const [allHosts, setAllHosts] = useState<Host[]>([]);
|
||||
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 [showDonationModal, setShowDonationModal] = useState(false);
|
||||
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
|
||||
@@ -1806,7 +1813,6 @@ export function AppShell({
|
||||
<UserProfilePanel
|
||||
username={username}
|
||||
onLogout={onLogout}
|
||||
onChangeServer={onChangeServer}
|
||||
userPrefs={userPrefs}
|
||||
onPrefsChange={(updates) =>
|
||||
setUserPrefs((current) => ({ ...current, ...updates }))
|
||||
@@ -1815,7 +1821,7 @@ export function AppShell({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{railView === "admin-settings" && isAdmin && (
|
||||
{railView === "admin-settings" && showMultiUserUI && (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<AdminSettingsPanel
|
||||
onEditingChange={setSidebarEditing}
|
||||
@@ -1870,181 +1876,194 @@ export function AppShell({
|
||||
|
||||
return (
|
||||
<ServerStatusProvider isAuthenticated={!!username}>
|
||||
<div className="flex w-screen bg-background" style={{ height: "100dvh" }}>
|
||||
{/* Skinny icon rail — desktop only, hidden on mobile */}
|
||||
<AppRail
|
||||
railView={railView}
|
||||
sidebarOpen={sidebarOpen}
|
||||
splitMode={splitMode}
|
||||
username={username}
|
||||
isAdmin={isAdmin}
|
||||
onRailClick={handleRailClick}
|
||||
onOpenTab={openSingletonTab}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
{/* Desktop: inline resizable sidebar */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className={`relative flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
|
||||
style={{
|
||||
width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
|
||||
transition: sidebarDragging ? "none" : "width 0.2s",
|
||||
<div
|
||||
className="flex flex-col w-screen bg-background"
|
||||
style={{ height: "100dvh" }}
|
||||
>
|
||||
{isElectron() && (
|
||||
<RemoteSyncBanner
|
||||
onReconnect={() => {
|
||||
setRailView("user-profile");
|
||||
if (!sidebarOpen) setSidebarOpen(true);
|
||||
}}
|
||||
>
|
||||
{sidebarHeader}
|
||||
{sidebarPanelContent}
|
||||
|
||||
{sidebarOpen && !sidebarEditing && (
|
||||
<div
|
||||
onMouseDown={onSidebarMouseDown}
|
||||
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile: sidebar as overlay sheet */}
|
||||
{isMobile && (
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="p-0 flex flex-col min-h-0 w-[min(85vw,360px)] max-w-full bg-sidebar border-r border-border gap-0"
|
||||
style={{ height: "100dvh" }}
|
||||
>
|
||||
{sidebarHeader}
|
||||
{sidebarPanelContent}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-all duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
|
||||
>
|
||||
{!isMobile && !sidebarOpen && (
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
title="Open Sidebar"
|
||||
className="absolute left-0 top-0 bottom-0 z-20 flex items-center justify-center w-6 bg-sidebar border-r border-border text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/5 transition-colors"
|
||||
>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
splitMode={splitMode}
|
||||
paneTabIds={paneTabIds}
|
||||
focusedPaneIndex={focusedPaneIndex}
|
||||
onSetActiveTab={setActiveTabId}
|
||||
onCloseTab={closeTab}
|
||||
onRefreshTab={refreshTab}
|
||||
onReorderTabs={setTabs}
|
||||
onSplitTab={splitTabQuick}
|
||||
onAddToSplit={addTabToSplit}
|
||||
onRemoveFromSplit={removeTabFromSplit}
|
||||
onRenameTab={renameTab}
|
||||
onOpenFileManager={(tabId) => {
|
||||
const targetTab = tabs.find((t) => t.id === tabId);
|
||||
if (targetTab?.host) openTab(targetTab.host, "files");
|
||||
}}
|
||||
onOpenShare={openShareForTab}
|
||||
isAppFullscreen={isAppFullscreen}
|
||||
onToggleAppFullscreen={toggleAppFullscreen}
|
||||
/>
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
display: isSplit ? "flex" : "none",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<SplitView
|
||||
tabs={tabs}
|
||||
paneTabIds={paneTabIds}
|
||||
splitMode={splitMode}
|
||||
focusedPaneIndex={focusedPaneIndex}
|
||||
onTerminalResize={resizeAllTerminals}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={setFocusedPaneIndex}
|
||||
onAssignPane={assignPane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Normal-view container. Tab nodes are appended here (or to pane elements)
|
||||
by the DOM-placement effect above. React portals each tab's content
|
||||
into its stable per-tab node so the component is never remounted.
|
||||
When split is active, shown on top only if the active tab is not in a pane. */}
|
||||
<div
|
||||
ref={normalViewRef}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
display:
|
||||
isSplit && !isMobile && paneTabIds.includes(activeTabId)
|
||||
? "none"
|
||||
: undefined,
|
||||
zIndex:
|
||||
isSplit && !paneTabIds.includes(activeTabId)
|
||||
? 10
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const tabNode = getTabNode(tab.id, tab.type === "terminal");
|
||||
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
|
||||
const inPane = paneIdx !== -1;
|
||||
const activeInline = !inPane && tab.id === activeTabId;
|
||||
return createPortal(
|
||||
renderTabContent(
|
||||
tab,
|
||||
openSingletonTab,
|
||||
openTab,
|
||||
closeTab,
|
||||
inPane || activeInline,
|
||||
(host, filePath) =>
|
||||
openTab(host, "files", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
initialFilePath: filePath,
|
||||
}),
|
||||
(host, _path) => openTab(host, "files"),
|
||||
(host, path) =>
|
||||
openTab(host, "terminal", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
initialFilePath: path,
|
||||
}),
|
||||
renameTab,
|
||||
saveQuickConnectHost,
|
||||
),
|
||||
tabNode,
|
||||
tab.id,
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom nav bar — mobile only */}
|
||||
<MobileBottomBar
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Skinny icon rail — desktop only, hidden on mobile */}
|
||||
<AppRail
|
||||
railView={railView}
|
||||
sidebarOpen={sidebarOpen}
|
||||
splitMode={splitMode}
|
||||
username={username}
|
||||
isAdmin={showMultiUserUI}
|
||||
onRailClick={handleRailClick}
|
||||
onOpenTab={openSingletonTab}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
{/* Desktop: inline resizable sidebar */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className={`relative flex flex-col min-h-0 bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
|
||||
style={{
|
||||
width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
|
||||
transition: sidebarDragging ? "none" : "width 0.2s",
|
||||
}}
|
||||
>
|
||||
{sidebarHeader}
|
||||
{sidebarPanelContent}
|
||||
|
||||
{sidebarOpen && !sidebarEditing && (
|
||||
<div
|
||||
onMouseDown={onSidebarMouseDown}
|
||||
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: sidebar as overlay sheet */}
|
||||
{isMobile && (
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="p-0 flex flex-col min-h-0 w-[min(85vw,360px)] max-w-full bg-sidebar border-r border-border gap-0"
|
||||
style={{ height: "100dvh" }}
|
||||
>
|
||||
{sidebarHeader}
|
||||
{sidebarPanelContent}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-all duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
|
||||
>
|
||||
{!isMobile && !sidebarOpen && (
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
title="Open Sidebar"
|
||||
className="absolute left-0 top-0 bottom-0 z-20 flex items-center justify-center w-6 bg-sidebar border-r border-border text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/5 transition-colors"
|
||||
>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
splitMode={splitMode}
|
||||
paneTabIds={paneTabIds}
|
||||
focusedPaneIndex={focusedPaneIndex}
|
||||
onSetActiveTab={setActiveTabId}
|
||||
onCloseTab={closeTab}
|
||||
onRefreshTab={refreshTab}
|
||||
onReorderTabs={setTabs}
|
||||
onSplitTab={splitTabQuick}
|
||||
onAddToSplit={addTabToSplit}
|
||||
onRemoveFromSplit={removeTabFromSplit}
|
||||
onRenameTab={renameTab}
|
||||
onOpenFileManager={(tabId) => {
|
||||
const targetTab = tabs.find((t) => t.id === tabId);
|
||||
if (targetTab?.host) openTab(targetTab.host, "files");
|
||||
}}
|
||||
onOpenShare={openShareForTab}
|
||||
isAppFullscreen={isAppFullscreen}
|
||||
onToggleAppFullscreen={toggleAppFullscreen}
|
||||
/>
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
display: isSplit ? "flex" : "none",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<SplitView
|
||||
tabs={tabs}
|
||||
paneTabIds={paneTabIds}
|
||||
splitMode={splitMode}
|
||||
focusedPaneIndex={focusedPaneIndex}
|
||||
onTerminalResize={resizeAllTerminals}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
onPaneClick={setFocusedPaneIndex}
|
||||
onAssignPane={assignPane}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Normal-view container. Tab nodes are appended here (or to pane elements)
|
||||
by the DOM-placement effect above. React portals each tab's content
|
||||
into its stable per-tab node so the component is never remounted.
|
||||
When split is active, shown on top only if the active tab is not in a pane. */}
|
||||
<div
|
||||
ref={normalViewRef}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
display:
|
||||
isSplit && !isMobile && paneTabIds.includes(activeTabId)
|
||||
? "none"
|
||||
: undefined,
|
||||
zIndex:
|
||||
isSplit && !paneTabIds.includes(activeTabId)
|
||||
? 10
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const tabNode = getTabNode(tab.id, tab.type === "terminal");
|
||||
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
|
||||
const inPane = paneIdx !== -1;
|
||||
const activeInline = !inPane && tab.id === activeTabId;
|
||||
return createPortal(
|
||||
renderTabContent(
|
||||
tab,
|
||||
openSingletonTab,
|
||||
openTab,
|
||||
closeTab,
|
||||
inPane || activeInline,
|
||||
(host, filePath) =>
|
||||
openTab(host, "files", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
initialFilePath: filePath,
|
||||
}),
|
||||
(host, _path) => openTab(host, "files"),
|
||||
(host, path) =>
|
||||
openTab(host, "terminal", {
|
||||
instanceId:
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||
restoredSessionId: null,
|
||||
initialFilePath: path,
|
||||
}),
|
||||
renameTab,
|
||||
saveQuickConnectHost,
|
||||
),
|
||||
tabNode,
|
||||
tab.id,
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom nav bar — mobile only */}
|
||||
<MobileBottomBar
|
||||
railView={railView}
|
||||
sidebarOpen={sidebarOpen}
|
||||
splitMode={splitMode}
|
||||
onRailClick={handleRailClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { handleApiError, statsApi } from "@/main-axios";
|
||||
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 {
|
||||
ts: string;
|
||||
cpu_percent: number | null;
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
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 { 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: "info" | "success" | "warning" | "error";
|
||||
stage: string;
|
||||
@@ -76,6 +99,7 @@ export async function getAllServerStatuses(): Promise<
|
||||
> {
|
||||
return getCachedServerStatuses(async () => {
|
||||
let lastError: unknown = null;
|
||||
let localStatuses: Record<number, ServerStatus> = {};
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
||||
@@ -89,7 +113,9 @@ export async function getAllServerStatuses(): Promise<
|
||||
// blips don't look like real outages.
|
||||
__silentRetry: !isFinalAttempt,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
return response.data || {};
|
||||
localStatuses = response.data || {};
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isTransientStatusError(error)) {
|
||||
@@ -102,8 +128,24 @@ export async function getAllServerStatuses(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
if (lastError) {
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from "axios";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { authApi, getServerConfig, handleApiError } from "@/main-axios";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
export interface ResolvedShareLink {
|
||||
protocol: "ssh" | "rdp" | "vnc" | "telnet";
|
||||
@@ -30,17 +30,18 @@ const isDev = (): boolean =>
|
||||
window.location.port === "");
|
||||
|
||||
// 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> {
|
||||
if (isDev()) {
|
||||
const protocol = window.location.protocol === "https:" ? "https" : "http";
|
||||
return `${protocol}://localhost:30001`;
|
||||
}
|
||||
if (isElectron()) {
|
||||
const serverConfig = await getServerConfig();
|
||||
const configuredUrl = serverConfig?.serverUrl;
|
||||
if (configuredUrl) return configuredUrl.replace(/\/$/, "");
|
||||
return "http://localhost:30001";
|
||||
return "http://127.0.0.1:30001";
|
||||
}
|
||||
return getBasePath();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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 type { SSHHost } from "@/types/index";
|
||||
|
||||
@@ -72,7 +80,7 @@ export async function connectSSH(
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/connect",
|
||||
{ sessionId, ...config },
|
||||
{ timeout: 120000 },
|
||||
@@ -121,12 +129,15 @@ export async function disconnectSSH(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/disconnect", {
|
||||
sessionId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/disconnect",
|
||||
{ sessionId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "disconnect SSH");
|
||||
} finally {
|
||||
clearSessionOrigin(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,10 +146,10 @@ export async function verifySSHTOTP(
|
||||
totpCode: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/connect-totp", {
|
||||
sessionId,
|
||||
totpCode,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/connect-totp",
|
||||
{ sessionId, totpCode },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "verify SSH TOTP");
|
||||
@@ -149,9 +160,10 @@ export async function verifySSHWarpgate(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/connect-warpgate", {
|
||||
sessionId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/connect-warpgate",
|
||||
{ sessionId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "verify SSH Warpgate");
|
||||
@@ -239,9 +251,10 @@ export async function getSSHStatus(
|
||||
sessionId: string,
|
||||
): Promise<{ connected: boolean }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/status", {
|
||||
params: { sessionId },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/status",
|
||||
{ params: { sessionId } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get SSH status");
|
||||
@@ -252,9 +265,10 @@ export async function keepSSHAlive(
|
||||
sessionId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/keepalive", {
|
||||
sessionId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/keepalive",
|
||||
{ sessionId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "SSH keepalive");
|
||||
@@ -266,9 +280,10 @@ export async function listSSHFiles(
|
||||
path: string,
|
||||
): Promise<{ files: unknown[]; path: string }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/listFiles", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/listFiles",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data || { files: [], path };
|
||||
} catch (error) {
|
||||
handleApiError(error, "list SSH files");
|
||||
@@ -281,9 +296,10 @@ export async function identifySSHSymlink(
|
||||
path: string,
|
||||
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/identifySymlink", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/identifySymlink",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "identify SSH symlink");
|
||||
@@ -295,9 +311,10 @@ export async function resolveSSHPath(
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/resolvePath", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/resolvePath",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data?.resolvedPath || path;
|
||||
} catch {
|
||||
return path;
|
||||
@@ -313,9 +330,10 @@ export async function readSSHFile(
|
||||
encoding?: "base64" | "utf8";
|
||||
}> {
|
||||
try {
|
||||
const response = await fileManagerApi.get("/ssh/readFile", {
|
||||
params: { sessionId, path },
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).get(
|
||||
"/ssh/readFile",
|
||||
{ params: { sessionId, path } },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (error.response?.status === 404) {
|
||||
@@ -340,13 +358,10 @@ export async function writeSSHFile(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/writeFile", {
|
||||
sessionId,
|
||||
path,
|
||||
content,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/writeFile",
|
||||
{ sessionId, path, content, hostId, userId },
|
||||
);
|
||||
|
||||
if (
|
||||
response.data &&
|
||||
@@ -410,7 +425,7 @@ export async function uploadSSHFile(
|
||||
form.append("totalSize", String(file.size));
|
||||
form.append("chunk", chunkBlob, fileName);
|
||||
|
||||
const response = await fileManagerApi.postForm(
|
||||
const response = await getFileManagerApiForSession(sessionId).postForm(
|
||||
"/ssh/uploadFileChunk",
|
||||
form,
|
||||
{ timeout: 0 },
|
||||
@@ -444,7 +459,7 @@ export async function uploadSSHFile(
|
||||
if (userId !== undefined) form.append("userId", userId);
|
||||
form.append("file", file, fileName);
|
||||
|
||||
const response = await fileManagerApi.postForm(
|
||||
const response = await getFileManagerApiForSession(sessionId).postForm(
|
||||
"/ssh/uploadFileStream",
|
||||
form,
|
||||
{
|
||||
@@ -464,7 +479,7 @@ export async function downloadSSHFile(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/downloadFile",
|
||||
{
|
||||
sessionId,
|
||||
@@ -484,7 +499,7 @@ export async function downloadSSHFileStream(
|
||||
sessionId: string,
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/downloadFileStream",
|
||||
{ sessionId, path: filePath },
|
||||
{ responseType: "blob", timeout: 0 },
|
||||
@@ -503,14 +518,10 @@ export async function createSSHFile(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/createFile", {
|
||||
sessionId,
|
||||
path,
|
||||
fileName,
|
||||
content,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/createFile",
|
||||
{ sessionId, path, fileName, content, hostId, userId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create SSH file");
|
||||
@@ -525,13 +536,10 @@ export async function createSSHFolder(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post("/ssh/createFolder", {
|
||||
sessionId,
|
||||
path,
|
||||
folderName,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/createFolder",
|
||||
{ sessionId, path, folderName, hostId, userId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create SSH folder");
|
||||
@@ -546,15 +554,18 @@ export async function deleteSSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.delete("/ssh/deleteItem", {
|
||||
data: {
|
||||
sessionId,
|
||||
path,
|
||||
isDirectory,
|
||||
hostId,
|
||||
userId,
|
||||
const response = await getFileManagerApiForSession(sessionId).delete(
|
||||
"/ssh/deleteItem",
|
||||
{
|
||||
data: {
|
||||
sessionId,
|
||||
path,
|
||||
isDirectory,
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete SSH item");
|
||||
@@ -566,7 +577,7 @@ export async function setSudoPassword(
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fileManagerApi.post("/sudo-password", {
|
||||
await getFileManagerApiForSession(sessionId).post("/sudo-password", {
|
||||
sessionId,
|
||||
password,
|
||||
});
|
||||
@@ -583,7 +594,7 @@ export async function copySSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.post(
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/copyItem",
|
||||
{
|
||||
sessionId,
|
||||
@@ -611,13 +622,10 @@ export async function renameSSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.put("/ssh/renameItem", {
|
||||
sessionId,
|
||||
oldPath,
|
||||
newName,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).put(
|
||||
"/ssh/renameItem",
|
||||
{ sessionId, oldPath, newName, hostId, userId },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "rename SSH item");
|
||||
@@ -633,7 +641,7 @@ export async function moveSSHItem(
|
||||
userId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await fileManagerApi.put(
|
||||
const response = await getFileManagerApiForSession(sessionId).put(
|
||||
"/ssh/moveItem",
|
||||
{
|
||||
sessionId,
|
||||
@@ -670,13 +678,10 @@ export async function changeSSHPermissions(
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/changePermissions", {
|
||||
sessionId,
|
||||
path,
|
||||
permissions,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/changePermissions",
|
||||
{ sessionId, path, permissions, hostId, userId },
|
||||
);
|
||||
|
||||
fileLogger.success("SSH file permissions changed successfully", {
|
||||
operation: "change_permissions",
|
||||
@@ -715,13 +720,10 @@ export async function extractSSHArchive(
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/extractArchive", {
|
||||
sessionId,
|
||||
archivePath,
|
||||
extractPath,
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/extractArchive",
|
||||
{ sessionId, archivePath, extractPath, hostId, userId },
|
||||
);
|
||||
|
||||
fileLogger.success("Archive extracted successfully", {
|
||||
operation: "extract_archive",
|
||||
@@ -762,14 +764,17 @@ export async function compressSSHFiles(
|
||||
userId,
|
||||
});
|
||||
|
||||
const response = await fileManagerApi.post("/ssh/compressFiles", {
|
||||
sessionId,
|
||||
paths,
|
||||
archiveName,
|
||||
format: format || "zip",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
const response = await getFileManagerApiForSession(sessionId).post(
|
||||
"/ssh/compressFiles",
|
||||
{
|
||||
sessionId,
|
||||
paths,
|
||||
archiveName,
|
||||
format: format || "zip",
|
||||
hostId,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
|
||||
fileLogger.success("Files compressed successfully", {
|
||||
operation: "compress_files",
|
||||
@@ -811,6 +816,12 @@ export async function ensureSSHSessionForHost(
|
||||
host: SSHHost,
|
||||
): Promise<EnsureSSHSessionResult> {
|
||||
const sessionId = host.id.toString();
|
||||
const origin = await resolveConnectionOrigin({
|
||||
connectionType: host.connectionType,
|
||||
connectionOrigin: host.connectionOrigin,
|
||||
});
|
||||
setSessionOrigin(sessionId, origin);
|
||||
|
||||
try {
|
||||
const status = await getSSHStatus(sessionId);
|
||||
if (status?.connected) {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import axios from "axios";
|
||||
import { authApi, handleApiError, tunnelApi } from "@/main-axios";
|
||||
import {
|
||||
authApi,
|
||||
handleApiError,
|
||||
tunnelApi,
|
||||
getRemoteTunnelApi,
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type {
|
||||
C2STunnelPreset,
|
||||
TunnelConfig,
|
||||
@@ -9,13 +15,46 @@ import type {
|
||||
|
||||
// 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<
|
||||
Record<string, TunnelStatus>
|
||||
> {
|
||||
try {
|
||||
const response = await tunnelApi.get("/tunnel/status");
|
||||
return response.data || {};
|
||||
const [localResult, remoteConnected] = await Promise.all([
|
||||
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) {
|
||||
handleApiError(error, "fetch tunnel statuses");
|
||||
}
|
||||
@@ -30,9 +69,18 @@ export function subscribeTunnelStatuses(
|
||||
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) => {
|
||||
try {
|
||||
onStatuses(JSON.parse(event.data) as Record<string, TunnelStatus>);
|
||||
latestLocal = JSON.parse(event.data) as Record<string, TunnelStatus>;
|
||||
emitMerged();
|
||||
} catch {
|
||||
onError?.();
|
||||
}
|
||||
@@ -42,7 +90,27 @@ export function subscribeTunnelStatuses(
|
||||
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(
|
||||
|
||||
+51
-158
@@ -29,17 +29,13 @@ import {
|
||||
completePasswordReset,
|
||||
getOIDCAuthorizeUrl,
|
||||
verifyTOTPLogin,
|
||||
getServerConfig,
|
||||
saveServerConfig,
|
||||
isElectron,
|
||||
getEmbeddedServerStatus,
|
||||
getCurrentToken,
|
||||
getOidcSilentLoginDefault,
|
||||
requestDesktopAutoSession,
|
||||
} from "@/main-axios";
|
||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||
import type { SSOProviderPublic } from "@/types/index";
|
||||
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
|
||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import {
|
||||
@@ -263,13 +259,22 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const [firstUser, setFirstUser] = useState(false);
|
||||
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||
const [dbHealthChecking, setDbHealthChecking] = useState(true);
|
||||
|
||||
const [showServerConfig, setShowServerConfig] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const [currentServerUrl, setCurrentServerUrl] = useState("");
|
||||
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(() => {
|
||||
try {
|
||||
localStorage.setItem("rememberMe", rememberMe.toString());
|
||||
@@ -320,7 +325,11 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
}, []);
|
||||
|
||||
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);
|
||||
getSetupRequired()
|
||||
.then((res) => {
|
||||
@@ -332,53 +341,28 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
})
|
||||
.catch(() => setDbConnectionFailed(true))
|
||||
.finally(() => setDbHealthChecking(false));
|
||||
}, [showServerConfig]);
|
||||
}, [desktopAutoSessionDone]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkElectron = async () => {
|
||||
if (isInElectronWebView()) {
|
||||
setShowServerConfig(false);
|
||||
return;
|
||||
}
|
||||
if (isElectron()) {
|
||||
const forceShow = localStorage.getItem("termix_show_server_config");
|
||||
if (forceShow === "true") {
|
||||
localStorage.removeItem("termix_show_server_config");
|
||||
try {
|
||||
const config = await getServerConfig();
|
||||
setCurrentServerUrl(config?.serverUrl || "");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setShowServerConfig(true);
|
||||
if (desktopAutoSessionDone !== null) return;
|
||||
let cancelled = false;
|
||||
requestDesktopAutoSession()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res?.success) {
|
||||
storeAuth(res.username || "");
|
||||
onLogin(res.username || "", res.userId || undefined, !!res.is_admin);
|
||||
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);
|
||||
}
|
||||
setDesktopAutoSessionDone(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setDesktopAutoSessionDone(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
checkElectron();
|
||||
}, []);
|
||||
}, [desktopAutoSessionDone, onLogin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "totp" && totpInputRef.current) totpInputRef.current.focus();
|
||||
@@ -474,36 +458,6 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
}
|
||||
}, [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() {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
@@ -935,46 +889,19 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
oidcSilentLoginDefaultLoaded,
|
||||
]);
|
||||
|
||||
// Electron server config / webview auth success screens
|
||||
if (isElectron() && !isInElectronWebView()) {
|
||||
if (showServerConfig === null)
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
// Electron, non-iframed: wait for the auto-session probe before rendering
|
||||
// anything, so a standalone desktop install never flashes a login form
|
||||
// it's about to skip past.
|
||||
if (
|
||||
isElectron() &&
|
||||
!isInElectronWebView() &&
|
||||
desktopAutoSessionDone === null
|
||||
) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
if (webviewAuthSuccess || (isInElectronWebView() && webviewAuthSuccess))
|
||||
@@ -1018,30 +945,11 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
))}
|
||||
</select>
|
||||
</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>
|
||||
);
|
||||
|
||||
if (dbHealthChecking && showServerConfig === false)
|
||||
if (dbHealthChecking)
|
||||
return (
|
||||
<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" />
|
||||
@@ -1068,21 +976,6 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* 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">
|
||||
|
||||
@@ -7,6 +7,12 @@ interface ElectronLoginFormProps {
|
||||
serverUrl: string;
|
||||
onAuthSuccess: (token: string | null) => void | Promise<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([
|
||||
@@ -19,6 +25,7 @@ export function ElectronLoginForm({
|
||||
serverUrl,
|
||||
onAuthSuccess,
|
||||
onChangeServer,
|
||||
targetPurpose = "local",
|
||||
}: ElectronLoginFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -43,7 +50,11 @@ export function ElectronLoginForm({
|
||||
|
||||
try {
|
||||
if (token) {
|
||||
localStorage.setItem("jwt", token);
|
||||
if (targetPurpose === "remoteSync") {
|
||||
await window.electronAPI?.invoke?.("save-remote-sync-jwt", token);
|
||||
} else {
|
||||
localStorage.setItem("jwt", token);
|
||||
}
|
||||
}
|
||||
await onAuthSuccessRef.current(token);
|
||||
} catch {
|
||||
@@ -53,7 +64,7 @@ export function ElectronLoginForm({
|
||||
hasAuthenticatedRef.current = false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
[t, targetPurpose],
|
||||
);
|
||||
|
||||
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
getServerConfig,
|
||||
saveServerConfig,
|
||||
getEmbeddedServerStatus,
|
||||
setEmbeddedMode,
|
||||
type ServerConfig,
|
||||
} from "@/main-axios.ts";
|
||||
import { Server, Monitor, Loader2, ChevronDown, X } from "lucide-react";
|
||||
@@ -103,7 +102,7 @@ export function ElectronServerConfig({
|
||||
const checkEmbeddedBackend = async () => {
|
||||
try {
|
||||
const status = await getEmbeddedServerStatus();
|
||||
setEmbeddedAvailable(!!status?.embedded);
|
||||
setEmbeddedAvailable(!!status?.running);
|
||||
} catch {
|
||||
setEmbeddedAvailable(true);
|
||||
}
|
||||
@@ -144,7 +143,6 @@ export function ElectronServerConfig({
|
||||
const maxRetries = 15;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
if (await probeBackend()) {
|
||||
setEmbeddedMode(true);
|
||||
if (onUseEmbedded) {
|
||||
onUseEmbedded();
|
||||
} else {
|
||||
|
||||
@@ -24,10 +24,8 @@ import {
|
||||
completePasswordReset,
|
||||
getOIDCAuthorizeUrl,
|
||||
verifyTOTPLogin,
|
||||
getServerConfig,
|
||||
saveServerConfig,
|
||||
isElectron,
|
||||
getEmbeddedServerStatus,
|
||||
getCurrentToken,
|
||||
getOidcSilentLoginDefault,
|
||||
} from "@/main-axios";
|
||||
@@ -1019,41 +1017,14 @@ export function Auth({
|
||||
}, [dbConnectionFailed, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkServerConfig = async () => {
|
||||
if (isInElectronWebView()) {
|
||||
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();
|
||||
// The desktop app always runs its embedded local backend as the source
|
||||
// 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);
|
||||
}, []);
|
||||
|
||||
if (showServerConfig === null && !isInElectronWebView()) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
getServiceLinks,
|
||||
createServiceLink,
|
||||
deleteServiceLink,
|
||||
isElectron,
|
||||
} from "@/main-axios";
|
||||
import type { RecentActivityItem, ServiceLink } from "@/main-axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -1365,7 +1366,13 @@ export function DashboardTab({
|
||||
load();
|
||||
|
||||
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(() => {});
|
||||
getUptime()
|
||||
.then((u) => setUptimeFormatted(u.formatted))
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
} from "@/components/select.tsx";
|
||||
import { Card, CardContent } from "@/components/card.tsx";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import {
|
||||
resolveConnectionOrigin,
|
||||
buildOriginWsUrl,
|
||||
} from "@/lib/connection-origin.ts";
|
||||
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { SSHHost } from "@/types";
|
||||
@@ -265,7 +269,7 @@ export function ConsoleTerminal({
|
||||
}
|
||||
}, [terminal]);
|
||||
|
||||
const connect = React.useCallback(() => {
|
||||
const connect = React.useCallback(async () => {
|
||||
if (!terminal || containerState !== "running") {
|
||||
toast.error(t("docker.containerMustBeRunning"));
|
||||
return;
|
||||
@@ -287,20 +291,30 @@ export function ConsoleTerminal({
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
const baseWsUrl = isDev
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`
|
||||
: isElectronApp
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
(window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl || "http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost}/docker/console/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
|
||||
let baseWsUrl: string;
|
||||
if (isDev) {
|
||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`;
|
||||
} else if (isElectronApp) {
|
||||
const origin = await resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: hostConfig.connectionOrigin,
|
||||
});
|
||||
const resolvedUrl = await buildOriginWsUrl({
|
||||
origin,
|
||||
localPort: 30009,
|
||||
localPath: "/docker/console/",
|
||||
remotePath: "/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);
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
getGuacdStatus,
|
||||
getSSHHosts,
|
||||
logActivity,
|
||||
isElectron,
|
||||
} from "@/main-axios.ts";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
|
||||
@@ -172,19 +174,34 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
setToken(null);
|
||||
setGuacamoleConnectionId(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") {
|
||||
setError(t("guacamole.guacdUnavailable"));
|
||||
return;
|
||||
}
|
||||
return getGuacamoleTokenFromHost(
|
||||
const result = await getGuacamoleTokenFromHost(
|
||||
hostId,
|
||||
protocol,
|
||||
promptedCredentials ?? undefined,
|
||||
);
|
||||
})
|
||||
.then((result) => {
|
||||
if (result) {
|
||||
setToken(result.token);
|
||||
setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
|
||||
@@ -192,8 +209,12 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("guacamole.failedToConnect");
|
||||
setError(message || t("guacamole.failedToConnect"));
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
hostId,
|
||||
hostName,
|
||||
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
} from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
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 { getBasePath } from "@/lib/base-path.ts";
|
||||
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
|
||||
import {
|
||||
resolveConnectionOrigin,
|
||||
buildOriginWsUrl,
|
||||
} from "@/lib/connection-origin.ts";
|
||||
import {
|
||||
isFirefoxBrowser,
|
||||
isPasteShortcut,
|
||||
@@ -169,15 +173,31 @@ export const GuacamoleDisplay = forwardRef<
|
||||
connectionConfig.dpi,
|
||||
);
|
||||
|
||||
const wsBase = buildGuacamoleWebSocketBaseUrl({
|
||||
isDev,
|
||||
isElectronApp: isElectron(),
|
||||
isEmbeddedApp: isEmbeddedMode(),
|
||||
configuredServerUrl: (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl,
|
||||
basePath: getBasePath(),
|
||||
location: window.location,
|
||||
});
|
||||
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,
|
||||
isElectronApp: false,
|
||||
isEmbeddedApp: false,
|
||||
basePath: getBasePath(),
|
||||
location: window.location,
|
||||
});
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
@@ -193,7 +213,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[connectionConfig, onError],
|
||||
[connectionConfig, onError, t],
|
||||
);
|
||||
|
||||
const refreshKeyboardHandlers = useCallback(() => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { isEmbeddedMode } from "@/main-axios";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
|
||||
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
||||
@@ -101,30 +100,10 @@ export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
|
||||
// ── WebSocket (Electron) path ──────────────────────────────────────────
|
||||
|
||||
const buildWsUrl = useCallback(() => {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
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 base = "ws://127.0.0.1:30011";
|
||||
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(/\/$/, "");
|
||||
// Serial is always local -- the device is physically attached to this
|
||||
// desktop machine, so it never routes through a remote server.
|
||||
const token = localStorage.getItem("jwt");
|
||||
const base = `${wsProtocol}${wsHost}/serial/websocket/`;
|
||||
const base = "ws://127.0.0.1:30011";
|
||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { getServerConfig } from "@/main-axios";
|
||||
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
|
||||
const PING_INTERVAL_MS = 30000;
|
||||
@@ -24,6 +23,9 @@ interface TerminalWsMessage {
|
||||
|
||||
// Mirrors Terminal.tsx's baseWsUrl construction (dev/electron/embedded/prod).
|
||||
// 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> {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
@@ -36,17 +38,6 @@ async function resolveTerminalWsBaseUrl(): Promise<string> {
|
||||
return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
|
||||
}
|
||||
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";
|
||||
}
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
|
||||
@@ -17,16 +17,18 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import {
|
||||
resolveConnectionOrigin,
|
||||
buildOriginWsUrl,
|
||||
} from "@/lib/connection-origin.ts";
|
||||
import {
|
||||
getCookie,
|
||||
isElectron,
|
||||
isEmbeddedMode,
|
||||
logActivity,
|
||||
getSnippets,
|
||||
deleteCommandFromHistory,
|
||||
getCommandHistory,
|
||||
getHostPassword,
|
||||
getServerConfig,
|
||||
} from "@/main-axios.ts";
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
|
||||
@@ -973,52 +975,28 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
if (isDev) {
|
||||
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
|
||||
} else if (isElectron()) {
|
||||
let configuredUrl = (window as { configuredServerUrl?: string | null })
|
||||
.configuredServerUrl;
|
||||
|
||||
if (!configuredUrl && !isEmbeddedMode()) {
|
||||
try {
|
||||
const serverConfig = await getServerConfig();
|
||||
configuredUrl = serverConfig?.serverUrl || null;
|
||||
if (configuredUrl) {
|
||||
(
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
configuredServerUrl?: string | null;
|
||||
}
|
||||
).configuredServerUrl = configuredUrl;
|
||||
}
|
||||
} 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");
|
||||
const origin = await resolveConnectionOrigin({
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: hostConfig.connectionOrigin as
|
||||
| "local"
|
||||
| "remote"
|
||||
| null
|
||||
| undefined,
|
||||
});
|
||||
const resolvedUrl = await buildOriginWsUrl({
|
||||
origin,
|
||||
localPort: 30002,
|
||||
localPath: "",
|
||||
remotePath: "/ssh/websocket/",
|
||||
});
|
||||
if (!resolvedUrl) {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
updateConnectionError(t("errors.failedToLoadServer"));
|
||||
updateConnectionError(t("errors.remoteServerRequired"));
|
||||
isConnectingRef.current = false;
|
||||
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 {
|
||||
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -389,6 +389,37 @@
|
||||
"noSavedServers": "No saved servers",
|
||||
"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": {
|
||||
"error": "Version Check Error",
|
||||
"checkFailed": "Failed to check for updates",
|
||||
@@ -651,6 +682,11 @@
|
||||
"delayAfterMs": "Delay After (ms)",
|
||||
"useSocks5Proxy": "Use SOCKS5 Proxy",
|
||||
"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",
|
||||
"proxyPort": "Proxy Port",
|
||||
"proxyUsername": "Proxy Username",
|
||||
@@ -2360,7 +2396,8 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.",
|
||||
|
||||
+173
-109
@@ -51,7 +51,6 @@ import {
|
||||
tunnelLogger,
|
||||
fileLogger,
|
||||
statsLogger,
|
||||
systemLogger,
|
||||
dashboardLogger,
|
||||
type LogContext,
|
||||
} from "@/lib/frontend-logger";
|
||||
@@ -646,8 +645,6 @@ function isDev(): boolean {
|
||||
}
|
||||
|
||||
const apiHost = import.meta.env.VITE_API_HOST || "localhost";
|
||||
let configuredServerUrl: string | null = null;
|
||||
let embeddedMode = false;
|
||||
|
||||
export interface ServerConfig {
|
||||
serverUrl: string;
|
||||
@@ -665,6 +662,13 @@ interface AxiosErrorExtended extends AxiosError {
|
||||
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> {
|
||||
if (!isElectron()) return null;
|
||||
|
||||
@@ -674,7 +678,6 @@ export async function getServerConfig(): Promise<ServerConfig | null> {
|
||||
typeof globalThis & {
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: unknown;
|
||||
configuredServerUrl?: string;
|
||||
}
|
||||
).electronAPI?.invoke("get-server-config");
|
||||
return result;
|
||||
@@ -693,33 +696,15 @@ export async function saveServerConfig(config: ServerConfig): Promise<boolean> {
|
||||
typeof globalThis & {
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: unknown;
|
||||
configuredServerUrl?: string;
|
||||
}
|
||||
).electronAPI?.invoke("save-server-config", config);
|
||||
if (result?.success) {
|
||||
configuredServerUrl = config.serverUrl;
|
||||
(
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: unknown;
|
||||
configuredServerUrl?: string;
|
||||
}
|
||||
).configuredServerUrl = configuredServerUrl;
|
||||
updateApiInstances();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return !!result?.success;
|
||||
} catch (error) {
|
||||
console.error("Failed to save server config:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfiguredServerUrl(): string | null {
|
||||
return configuredServerUrl;
|
||||
}
|
||||
|
||||
export async function testServerConnection(
|
||||
serverUrl: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
@@ -732,7 +717,6 @@ export async function testServerConnection(
|
||||
typeof globalThis & {
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: unknown;
|
||||
configuredServerUrl?: string;
|
||||
}
|
||||
).electronAPI?.invoke("test-server-connection", serverUrl);
|
||||
return result;
|
||||
@@ -767,7 +751,6 @@ export async function checkElectronUpdate(): Promise<{
|
||||
typeof globalThis & {
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: unknown;
|
||||
configuredServerUrl?: string;
|
||||
}
|
||||
).electronAPI?.invoke("check-electron-update");
|
||||
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<{
|
||||
running: boolean;
|
||||
embedded: boolean;
|
||||
dataDir: string | null;
|
||||
} | null> {
|
||||
if (!isElectron()) return null;
|
||||
@@ -796,7 +783,6 @@ export async function getEmbeddedServerStatus(): Promise<{
|
||||
).electronAPI?.invoke("get-embedded-server-status");
|
||||
return result as {
|
||||
running: boolean;
|
||||
embedded: boolean;
|
||||
dataDir: string | null;
|
||||
} | null;
|
||||
} 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 {
|
||||
const devMode = isDev();
|
||||
const electronMode = isElectron();
|
||||
|
||||
if (electronMode) {
|
||||
if (embeddedMode && !configuredServerUrl) {
|
||||
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";
|
||||
// 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}`;
|
||||
} else if (devMode) {
|
||||
const protocol = window.location.protocol === "https:" ? "https" : "http";
|
||||
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() {
|
||||
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
|
||||
hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST");
|
||||
@@ -921,43 +1000,8 @@ export const appReadyPromise: Promise<void> = new Promise((resolve) => {
|
||||
});
|
||||
|
||||
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();
|
||||
_resolveAppReady();
|
||||
}
|
||||
initializeApiInstances();
|
||||
_resolveAppReady();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
@@ -966,29 +1010,6 @@ if (document.readyState === "loading") {
|
||||
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
|
||||
// ============================================================================
|
||||
@@ -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> {
|
||||
try {
|
||||
const response = await authApi.get("/users/count");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -124,6 +124,7 @@ function hostToSSHHost(h: Host): SSHHost {
|
||||
defaultPath: h.defaultPath ?? "",
|
||||
tunnelConnections: [],
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: h.connectionOrigin ?? null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
} as SSHHost;
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
getCommandHistoryEnabled,
|
||||
updateCommandHistoryEnabled,
|
||||
isElectron,
|
||||
getConfiguredServerUrl,
|
||||
getUserRoles,
|
||||
} from "@/main-axios";
|
||||
import {
|
||||
@@ -808,7 +807,7 @@ export function AdminSettingsPanel({
|
||||
try {
|
||||
const apiUrl = getDatabaseTransferUrl("export", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: getConfiguredServerUrl(),
|
||||
configuredServerUrl: null,
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
@@ -854,7 +853,7 @@ export function AdminSettingsPanel({
|
||||
try {
|
||||
const apiUrl = getDatabaseTransferUrl("import", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: getConfiguredServerUrl(),
|
||||
configuredServerUrl: null,
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
||||
import { getAlertFirings } from "@/api/alerts-api";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
export type RailView =
|
||||
| "hosts"
|
||||
@@ -247,8 +248,44 @@ export function AppRail({
|
||||
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 railButtons = buildRailButtons(splitMode, t, hiddenTabs);
|
||||
const effectiveHiddenTabs = isRemoteSyncConnected
|
||||
? hiddenTabs
|
||||
: new Set([...hiddenTabs, "termix-id"]);
|
||||
const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -97,6 +97,10 @@ export function createHostEditorForm(
|
||||
? "chain"
|
||||
: "single") as "single" | "chain",
|
||||
socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[],
|
||||
connectionOrigin: (host?.connectionOrigin ?? null) as
|
||||
| "local"
|
||||
| "remote"
|
||||
| null,
|
||||
enableTerminal: host?.enableTerminal ?? true,
|
||||
enableSessionLogging:
|
||||
host?.enableSessionLogging ?? d?.enableSessionLogging ?? true,
|
||||
@@ -322,6 +326,7 @@ export function buildHostEditorPayload(
|
||||
form.socks5ProxyMode === "single" ? form.socks5Password || null : null,
|
||||
socks5ProxyChain:
|
||||
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
|
||||
connectionOrigin: form.connectionOrigin,
|
||||
enableSsh: protocols.enableSsh,
|
||||
enableRdp: protocols.enableRdp,
|
||||
enableVnc: protocols.enableVnc,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { FolderPathPicker } from "./FolderPathPicker";
|
||||
import { getSSHFolders } from "@/main-axios";
|
||||
import { getSSHFolders, isElectron } from "@/main-axios";
|
||||
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
|
||||
|
||||
type HostEditorSetField = <K extends keyof HostEditorForm>(
|
||||
@@ -717,6 +717,31 @@ export function HostEditorGeneralTab({
|
||||
) : null}
|
||||
</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 items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<option value="">
|
||||
{t("hosts.guac.selectCredential")}
|
||||
</option>
|
||||
<option value="">{t("hosts.guac.selectCredential")}</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username ? `${c.name} (${c.username})` : c.name}
|
||||
@@ -255,9 +253,7 @@ export function HostEditorRdpTab({
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.rdpPassword}
|
||||
onChange={(e) =>
|
||||
setField("rdpPassword", e.target.value)
|
||||
}
|
||||
onChange={(e) => setField("rdpPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -32,30 +32,10 @@ export function SerialPanel({ onConnect }: SerialPanelProps) {
|
||||
const [loadingPorts, setLoadingPorts] = useState(false);
|
||||
|
||||
const buildWsUrl = () => {
|
||||
const isDev =
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(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 base = "ws://127.0.0.1:30011";
|
||||
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(/\/$/, "");
|
||||
// Serial is always local -- the device is physically attached to this
|
||||
// desktop machine, so it never routes through a remote server.
|
||||
const token = localStorage.getItem("jwt");
|
||||
const base = `${wsProtocol}${wsHost}/serial/websocket/`;
|
||||
const base = "ws://127.0.0.1:30011";
|
||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
getUserRoles,
|
||||
saveUserPreferences,
|
||||
getUserPreferences,
|
||||
getConfiguredServerUrl,
|
||||
} from "@/main-axios";
|
||||
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
|
||||
import {
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
import type { UserRole } from "@/main-axios";
|
||||
import type React from "react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { RemoteSyncPanel } from "@/settings/RemoteSyncPanel.tsx";
|
||||
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
@@ -426,13 +426,11 @@ function PasswordChangeSection({
|
||||
export function UserProfilePanel({
|
||||
username,
|
||||
onLogout,
|
||||
onChangeServer,
|
||||
userPrefs,
|
||||
onPrefsChange,
|
||||
}: {
|
||||
username?: string;
|
||||
onLogout?: () => void;
|
||||
onChangeServer?: () => void;
|
||||
userPrefs?: {
|
||||
reopenTabsOnLogin: boolean;
|
||||
storageMode?: string | null;
|
||||
@@ -541,6 +539,46 @@ export function UserProfilePanel({
|
||||
}
|
||||
}, [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
|
||||
const [commandAutocomplete, setCommandAutocomplete] = useState(
|
||||
() => localStorage.getItem("commandAutocomplete") === "true",
|
||||
@@ -1143,7 +1181,7 @@ export function UserProfilePanel({
|
||||
try {
|
||||
const apiUrl = getDatabaseTransferUrl("export", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: getConfiguredServerUrl(),
|
||||
configuredServerUrl: null,
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
@@ -1171,9 +1209,7 @@ export function UserProfilePanel({
|
||||
toast.success(t("newUi.sidebar.userProfile.exportSuccess"));
|
||||
} else {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
toast.error(
|
||||
err.error || t("newUi.sidebar.userProfile.exportFailed"),
|
||||
);
|
||||
toast.error(err.error || t("newUi.sidebar.userProfile.exportFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.userProfile.exportFailed"));
|
||||
@@ -1191,7 +1227,7 @@ export function UserProfilePanel({
|
||||
try {
|
||||
const apiUrl = getDatabaseTransferUrl("import", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: getConfiguredServerUrl(),
|
||||
configuredServerUrl: null,
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
@@ -1280,44 +1316,48 @@ export function UserProfilePanel({
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Storage mode toggle */}
|
||||
<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">
|
||||
{t("newUi.sidebar.userProfile.storageModeSwitch")}
|
||||
</span>
|
||||
<div className="flex border border-border overflow-hidden w-full">
|
||||
{/* 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">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.storageModeSwitch")}
|
||||
</span>
|
||||
<div className="flex border border-border overflow-hidden w-full">
|
||||
<button
|
||||
onClick={() => handleStorageModeChange("local")}
|
||||
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
||||
storageMode === "local"
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.storageModeLocal")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStorageModeChange("cloud")}
|
||||
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
||||
storageMode === "cloud"
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.storageModeCloud")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("newUi.sidebar.userProfile.storageModeDescription")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleStorageModeChange("local")}
|
||||
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
||||
storageMode === "local"
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
onClick={resetToDefaults}
|
||||
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.storageModeLocal")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStorageModeChange("cloud")}
|
||||
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
||||
storageMode === "cloud"
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.storageModeCloud")}
|
||||
<RotateCcw className="size-3" />
|
||||
{t("newUi.sidebar.userProfile.resetToDefaults")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("newUi.sidebar.userProfile.storageModeDescription")}
|
||||
</p>
|
||||
<button
|
||||
onClick={resetToDefaults}
|
||||
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
{t("newUi.sidebar.userProfile.resetToDefaults")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account */}
|
||||
<AccordionSection
|
||||
@@ -1439,27 +1479,9 @@ export function UserProfilePanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isElectron() && onChangeServer && (
|
||||
{isElectron() && (
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<RemoteSyncPanel />
|
||||
</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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user