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