mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
v2.2.0 (#738)
* Improve Docker container list UI * Rework SSH tunnel forwarding * Update macOS Electron packaging * Optimize frontend bundle splitting * Add beta version update status * Add client tunnel preset management * Secure cookie authentication flows * Add client tunnel bridge support * Preserve sessions on restart * Update runtime to Node 24 * Add client remote tunnel support * Fix stale frontend cache handling * Fix Docker image platforms for Node 24 * Fix Electron packaging workflows * Fix client auth cache after upgrades * chore: cleanup files * fix: npm i error * Fix OIDC auth cookie readiness * Fix Docker npm ci config * Add react-is peer dependency * Fix Electron auth and cache handling * Improve terminal clipboard and refresh actions * feat: add API keys * feat: improve lazy loading with loading spinners * feat: Introduce FolderTree component with lazy-loading and motion animations for improved file manager UX (#735) * feat: integrate FolderTree component with lazy-loading for file manager sidebar - Add motion animation library (v12.38.0) for smooth UI transitions - Create new FolderTree component with advanced keyboard navigation support - Refactor kbd component: introduce KbdKey and KbdSeparator subcomponents - Implement lazy-loading strategy for directory tree in FileManagerSidebar - Refactor FileManagerSidebar with improved code organization and better separation of concerns - Update keyboard shortcut displays across CommandPalette, FileViewer, and Dashboard - Change React/ReactDOM dependency flags from dev to devOptional in package-lock.json BREAKING CHANGE: KbdGroup component has been replaced. Use <Kbd><KbdKey>...</KbdKey><KbdSeparator /></Kbd> instead. - Improves UX with smooth animations and better folder navigation - Reduces initial load time through lazy-loading subdirectories - Enhances accessibility with ARIA labels and keyboard navigation - Maintains dark mode support and proper styling * fix: incorrect use of the theme system and linked file manger sidebar with current folder --------- Co-authored-by: suryacagur <suryacagur.dev@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Enhance VNC token generation to include optional username parameter and refactor username input handling in HostGeneralTab (#733) * Fix Docker build info generation * Remove unused node-fetch dependency * feat: prompt user for SSH key passphrase on use (#715) When an encrypted SSH key has no stored passphrase, show a lightweight dialog prompting the user to enter it at connection time instead of failing with a parse error. Supports both desktop and mobile terminals. Closes Termix-SSH/Support#354 * fix: prevent session crash when uploading to permission-denied directory (#716) - Wrap writeFile sftp.stat callback in try-catch to prevent uncaught exceptions from escaping the callback into the event loop - Add missing stream.stderr error handler in writeFile fallback to prevent unhandled error events from crashing the process - Remove bogus activeOperations decrement in both writeFile and uploadFile fallback methods (counter was never incremented) - Add res.headersSent checks in fallback disconnect paths to prevent ERR_HTTP_HEADERS_SENT crashes Closes Termix-SSH/Support#652 * feat: add LOG_TIMESTAMP_FORMAT env var for 24h/ISO log timestamps (#718) Support LOG_TIMESTAMP_FORMAT environment variable with values: - "24h": 24-hour format (14:58:45) - "iso": ISO 8601 format (2026-04-25T14:58:45.000Z) - default: locale format (2:58:45 PM) Closes Termix-SSH/Support#650 * feat: open file manager at terminal current working directory (#719) * feat: open file manager at terminal current working directory When right-clicking in the terminal and selecting "Open File Manager Here", query the current working directory via a separate SSH exec channel and pass it as the initial path to the file manager tab. Closes Termix-SSH/Support#649 * chore: sync package-lock.json with node-fetch and deps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove undefined TerminalContextMenu from bad merge resolution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: show reconnect overlay when SSH server reboots (#720) When the remote server reboots, the SSH connection closes while the stream is still active. The close handler only sent the "disconnected" message when sshStream was null, so the frontend never received the disconnect notification and hung with a blinking cursor. Change the else-if condition to always send the "disconnected" message regardless of stream state. Closes Termix-SSH/Support#648 * feat: support read-only Docker container mode (#721) Move nginx runtime files (config, pid, logs, temp dirs) from /app/nginx/ to /tmp/nginx/ so the container can run with read_only: true. Template files remain in /app/nginx/ as read-only assets. Users can now harden the container with: read_only: true tmpfs: - /tmp Closes Termix-SSH/Support#647 * fix: allow editing host folder without re-entering password (#722) When editing an existing host, the password field is stripped by the backend for security. The form validation treated the empty password as invalid, disabling the Update Host button even for non-auth changes like folder assignment. Use an "existing_password" sentinel (mirroring the existing "existing_key" pattern) to represent an unchanged password during editing, skip validation for it, and omit it from the update payload. Closes Termix-SSH/Support#645 * fix: auto-close tab on graceful SSH disconnect (exit/Ctrl+D) (#723) Distinguish between graceful shell exit and unexpected disconnection using the stream close event's exit code. When the shell exits normally (code != null), send "session_ended" instead of "disconnected". The frontend auto-closes the tab on session_ended, and shows the reconnect overlay only on unexpected disconnections. Closes Termix-SSH/Support#643 * fix: reattach existing SSH session on WebSocket reconnect (#724) WebSocket reconnection was always creating a new SSH connection with full authentication instead of reattaching to the existing SSH session. The condition `!isReconnectingRef.current` prevented session reattach during reconnection, causing repeated password auth attempts that trigger SSHGuard/fail2ban blocking. Remove the guard so reconnection tries to reattach the persisted session first. If the session has expired, the backend sends sessionExpired and the frontend falls back to a new connection. Closes Termix-SSH/Support#644 * fix: prevent browser crash when uploading large files (>100MB) (#725) The file-to-base64 conversion used a byte-by-byte string concatenation loop (String.fromCharCode + btoa), which allocated ~3x the file size in intermediate strings, causing the browser tab to OOM on files over ~100MB. Replace with FileReader.readAsDataURL which delegates base64 encoding to the browser engine natively, avoiding the intermediate allocations. Closes Termix-SSH/Support#577 * fix: support SSH multi-factor auth with publickey + password (#726) When sshd requires AuthenticationMethods publickey,password, the connection failed because the key auth branch only set privateKey without also setting password. After publickey partial auth succeeded, ssh2 sent keyboard-interactive (due to tryKeyboard:true) instead of password, which the server rejected. Pass the credential password alongside the private key so ssh2 can complete the password step after publickey succeeds. Closes Termix-SSH/Support#629 * feat(oidc): add OIDC_ALLOW_REGISTRATION env to bypass allow_registration for OIDC (#727) The `allow_registration` setting blocks both password-based and OIDC user creation. Admins who want to close password registration but still onboard new users via a trusted IdP (with the existing `OIDC_ALLOWED_USERS` whitelist) have no way to do that today. Introduce an `OIDC_ALLOW_REGISTRATION` env var. When set to `true`, the OIDC callback skips the `allow_registration` settings check while still honoring the `OIDC_ALLOWED_USERS` whitelist. Password registration via `POST /users/create` continues to respect `allow_registration`. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf: lazy load locales, file previews, and decouple startup imports (#729) * perf: lazy load locale bundles * perf: lazy load file preview modules * perf: avoid eager api client load on startup * chore: remove dead code, tighten types, fix lint warnings (#730) * chore: clean up low-risk lint warnings * chore: tighten utility types * chore: preserve backend error causes * chore: simplify command palette host state * chore: remove unused frontend code * chore: prune stale frontend state * chore: trim unused navigation code * chore: prune unused user settings props * chore: trim unused sidebar state * chore: remove stale host editor imports * chore: tighten shared frontend types * chore: narrow desktop helper types * chore: type network topology data * chore: type connection log errors * chore: use typed tab context * chore: type api client error metadata * chore: tighten terminal config types * chore: type host proxy chains * chore: type host editor form data * chore: use typed host viewer fields * chore: format app builder patch script * Fix client auth cache after upgrades * chore: fix pr checks after dev merge * fix: remove duplicate session-expired useEffect in FullScreenAppWrapper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Xenthys <x@dis.gg> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: npm package warnings * feat: reconnect after file manager disconnects * feat: add docs button in api keys * feat: change colors for server tunnels * fix: fetch password from API for Copy Password button (#736) * chore: update readme's * feat: improve c2s UI in user profile * feat: improve ssh key detection and move open file manager at path for terminal button * fix: restore missing getHostPassword import in Tab.tsx (#737) * fix: security related fixes * feat: improve alert code * Fix Electron clipboard handling * fix: untranslated alert text --------- Co-authored-by: Xenthys <x@dis.gg> Co-authored-by: PT Kelana Tech Solutions <ptkelanatechsolutions@gmail.com> Co-authored-by: suryacagur <suryacagur.dev@gmail.com> Co-authored-by: zimmra <28514085+zimmra@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Fuad <funtik1229@yandex.ru>
This commit is contained in:
@@ -80,7 +80,7 @@ export function ServerStatusProvider({
|
||||
return prev;
|
||||
});
|
||||
return enabled;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return new Set<number>();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
@@ -111,7 +111,7 @@ export function ServerStatusProvider({
|
||||
}
|
||||
|
||||
setStatuses(newStatuses);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
setStatuses((prev) => {
|
||||
const updated = new Map(prev);
|
||||
|
||||
+190
-62
@@ -1,16 +1,15 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
Component,
|
||||
type ErrorInfo,
|
||||
Suspense,
|
||||
lazy,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx";
|
||||
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
|
||||
import { AppView } from "@/ui/desktop/navigation/AppView.tsx";
|
||||
import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager.tsx";
|
||||
import {
|
||||
TabProvider,
|
||||
useTabs,
|
||||
@@ -18,16 +17,47 @@ import {
|
||||
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
|
||||
import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { ServerStatusProvider } from "@/ui/contexts/ServerStatusContext";
|
||||
import { AdminSettings } from "@/ui/desktop/apps/admin/AdminSettings.tsx";
|
||||
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
|
||||
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
|
||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";
|
||||
import { getUserInfo, logoutUser, isElectron } from "@/ui/main-axios.ts";
|
||||
import {
|
||||
getUserInfo,
|
||||
logoutUser,
|
||||
isCurrentAuthInvalidationError,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
const Dashboard = lazy(() =>
|
||||
import("@/ui/desktop/apps/dashboard/Dashboard.tsx").then((module) => ({
|
||||
default: module.Dashboard,
|
||||
})),
|
||||
);
|
||||
const HostManager = lazy(() =>
|
||||
import("@/ui/desktop/apps/host-manager/hosts/HostManager.tsx").then(
|
||||
(module) => ({
|
||||
default: module.HostManager,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const AdminSettings = lazy(() =>
|
||||
import("@/ui/desktop/apps/admin/AdminSettings.tsx").then((module) => ({
|
||||
default: module.AdminSettings,
|
||||
})),
|
||||
);
|
||||
const UserProfile = lazy(() =>
|
||||
import("@/ui/desktop/user/UserProfile.tsx").then((module) => ({
|
||||
default: module.UserProfile,
|
||||
})),
|
||||
);
|
||||
const CommandPalette = lazy(() =>
|
||||
import("@/ui/desktop/apps/command-palette/CommandPalette.tsx").then(
|
||||
(module) => ({
|
||||
default: module.CommandPalette,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
function AppContent({
|
||||
onAuthStateChange,
|
||||
@@ -52,6 +82,7 @@ function AppContent({
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
|
||||
const isAuthenticatedRef = useRef(false);
|
||||
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
@@ -96,6 +127,8 @@ function AppContent({
|
||||
|
||||
const handleSessionExpired = () => {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
};
|
||||
|
||||
dbHealthMonitor.on(
|
||||
@@ -177,9 +210,8 @@ function AppContent({
|
||||
if (hostIdentifier) {
|
||||
const openTerminal = async () => {
|
||||
try {
|
||||
const { getSSHHostById, getSSHHosts } = await import(
|
||||
"@/ui/main-axios.ts"
|
||||
);
|
||||
const { getSSHHostById, getSSHHosts } =
|
||||
await import("@/ui/main-axios.ts");
|
||||
let host = null;
|
||||
|
||||
if (/^\d+$/.test(hostIdentifier)) {
|
||||
@@ -211,6 +243,22 @@ function AppContent({
|
||||
}, [addTab]);
|
||||
|
||||
const isCheckingAuth = useRef(false);
|
||||
const clientTunnelAutoStartStarted = useRef(false);
|
||||
|
||||
const startClientTunnelAutoStart = useCallback(() => {
|
||||
if (
|
||||
clientTunnelAutoStartStarted.current ||
|
||||
!window.electronAPI?.isElectron
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
clientTunnelAutoStartStarted.current = true;
|
||||
window.electronAPI.startC2SAutoStartTunnels?.().catch((error) => {
|
||||
clientTunnelAutoStartStarted.current = false;
|
||||
console.error("Failed to start client tunnel auto-start entries:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
@@ -227,16 +275,22 @@ function AppContent({
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
startClientTunnelAutoStart();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
if (isCurrentAuthInvalidationError(err)) {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
console.warn("Session expired - please log in again");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAuthenticatedRef.current) {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -251,7 +305,7 @@ function AppContent({
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, []);
|
||||
}, [startClientTunnelAutoStart]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
|
||||
@@ -259,6 +313,7 @@ function AppContent({
|
||||
|
||||
useEffect(() => {
|
||||
onAuthStateChange?.(isAuthenticated);
|
||||
isAuthenticatedRef.current = isAuthenticated;
|
||||
}, [isAuthenticated, onAuthStateChange]);
|
||||
|
||||
const handleAuthSuccess = useCallback(
|
||||
@@ -274,6 +329,7 @@ function AppContent({
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(authData.isAdmin);
|
||||
setUsername(authData.username);
|
||||
startClientTunnelAutoStart();
|
||||
setTransitionPhase("fadeIn");
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -282,7 +338,7 @@ function AppContent({
|
||||
}, 800);
|
||||
}, 1200);
|
||||
},
|
||||
[],
|
||||
[startClientTunnelAutoStart],
|
||||
);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
@@ -347,18 +403,22 @@ function AppContent({
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden bg-background">
|
||||
<CommandPalette
|
||||
isOpen={isCommandPaletteOpen}
|
||||
setIsOpen={setIsCommandPaletteOpen}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<CommandPalette
|
||||
isOpen={isCommandPaletteOpen}
|
||||
setIsOpen={setIsCommandPaletteOpen}
|
||||
/>
|
||||
</Suspense>
|
||||
{!isAuthenticated && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
|
||||
<Dashboard
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<Dashboard
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -382,56 +442,124 @@ function AppContent({
|
||||
|
||||
{showHome && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<Dashboard
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className="bg-canvas rounded-lg border-2 border-edge relative"
|
||||
style={{
|
||||
margin: "74px 17px 8px 8px",
|
||||
height: "calc(100vh - 82px)",
|
||||
}}
|
||||
>
|
||||
<SimpleLoader
|
||||
visible={true}
|
||||
message={t("common.loading")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Dashboard
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
onAuthSuccess={handleAuthSuccess}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSshManager && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<HostManager
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
initialTab={currentTabData?.initialTab}
|
||||
hostConfig={currentTabData?.hostConfig}
|
||||
_updateTimestamp={currentTabData?._updateTimestamp}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
currentTabId={currentTab}
|
||||
updateTab={updateTab}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className="bg-canvas rounded-lg border-2 border-edge relative"
|
||||
style={{
|
||||
margin: "74px 17px 8px 8px",
|
||||
height: "calc(100vh - 82px)",
|
||||
}}
|
||||
>
|
||||
<SimpleLoader
|
||||
visible={true}
|
||||
message={t("common.loading")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HostManager
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
initialTab={currentTabData?.initialTab}
|
||||
hostConfig={currentTabData?.hostConfig}
|
||||
_updateTimestamp={currentTabData?._updateTimestamp}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
currentTabId={currentTab}
|
||||
updateTab={updateTab}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdmin && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<AdminSettings
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className="bg-canvas rounded-lg border-2 border-edge relative"
|
||||
style={{
|
||||
margin: "74px 17px 8px 8px",
|
||||
height: "calc(100vh - 82px)",
|
||||
}}
|
||||
>
|
||||
<SimpleLoader
|
||||
visible={true}
|
||||
message={t("common.loading")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AdminSettings
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProfile && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-auto thin-scrollbar">
|
||||
<UserProfile
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className="bg-canvas rounded-lg border-2 border-edge relative"
|
||||
style={{
|
||||
margin: "74px 17px 8px 8px",
|
||||
height: "calc(100vh - 82px)",
|
||||
}}
|
||||
>
|
||||
<SimpleLoader
|
||||
visible={true}
|
||||
message={t("common.loading")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<UserProfile
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
initialTab={currentTabData?.initialTab}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TopNavbar
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
setIsTopbarOpen={setIsTopbarOpen}
|
||||
onOpenCommandPalette={() => setIsCommandPaletteOpen(true)}
|
||||
onRightSidebarStateChange={(isOpen, width) => {
|
||||
setRightSidebarOpen(isOpen);
|
||||
setRightSidebarWidth(width);
|
||||
@@ -643,7 +771,7 @@ class TabErrorBoundary extends Component<
|
||||
throw error;
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
|
||||
if (error.message?.includes("useTabs must be used within a TabProvider")) {
|
||||
console.warn(
|
||||
"TabProvider mounting race condition detected, recovering...",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getSSHHosts, getUserInfo } from "@/ui/main-axios.ts";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
|
||||
|
||||
interface FullScreenAppWrapperProps {
|
||||
hostId?: string;
|
||||
@@ -20,7 +21,18 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [, setIsAdmin] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSessionExpired = () => {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setHostConfig(null);
|
||||
};
|
||||
|
||||
dbHealthMonitor.on("session-expired", handleSessionExpired);
|
||||
return () => dbHealthMonitor.off("session-expired", handleSessionExpired);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
@@ -28,9 +40,8 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
const userInfo = await getUserInfo();
|
||||
if (userInfo) {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(userInfo.isAdmin || false);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
@@ -65,13 +76,8 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
}
|
||||
}, [hostId, isAuthenticated, authLoading]);
|
||||
|
||||
const handleAuthSuccess = (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => {
|
||||
const handleAuthSuccess = () => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(authData.isAdmin);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { Shield, Users, Database, Clock } from "lucide-react";
|
||||
import { Shield, Users, Database, Clock, Key } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
@@ -22,12 +22,14 @@ import {
|
||||
getSessions,
|
||||
unlinkOIDCFromPasswordAccount,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
import { RolesTab } from "@/ui/desktop/apps/admin/tabs/RolesTab.tsx";
|
||||
import { GeneralSettingsTab } from "@/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx";
|
||||
import { OIDCSettingsTab } from "@/ui/desktop/apps/admin/tabs/OIDCSettingsTab.tsx";
|
||||
import { UserManagementTab } from "@/ui/desktop/apps/admin/tabs/UserManagementTab.tsx";
|
||||
import { SessionManagementTab } from "@/ui/desktop/apps/admin/tabs/SessionManagementTab.tsx";
|
||||
import { DatabaseSecurityTab } from "@/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx";
|
||||
import { ApiKeysTab } from "@/ui/desktop/apps/admin/tabs/ApiKeysTab.tsx";
|
||||
import { CreateUserDialog } from "./dialogs/CreateUserDialog.tsx";
|
||||
import { UserEditDialog } from "./dialogs/UserEditDialog.tsx";
|
||||
import { LinkAccountDialog } from "./dialogs/LinkAccountDialog.tsx";
|
||||
@@ -47,6 +49,7 @@ export function AdminSettings({
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||
const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true);
|
||||
const [allowPasswordReset, setAllowPasswordReset] = React.useState(true);
|
||||
@@ -102,8 +105,8 @@ export function AdminSettings({
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastActiveAt: string;
|
||||
jwtToken: string;
|
||||
isRevoked?: boolean;
|
||||
isCurrentSession?: boolean;
|
||||
}>
|
||||
>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = React.useState(false);
|
||||
@@ -119,36 +122,45 @@ export function AdminSettings({
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getAdminOIDCConfig()
|
||||
.then((res) => {
|
||||
if (res) setOidcConfig(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!err.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchOidcConfig"));
|
||||
}
|
||||
});
|
||||
getUserInfo()
|
||||
.then((info) => {
|
||||
if (info) {
|
||||
setCurrentUser({
|
||||
id: info.userId,
|
||||
username: info.username,
|
||||
is_admin: info.is_admin,
|
||||
is_oidc: info.is_oidc,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!err?.message?.includes("No server configured")) {
|
||||
console.warn("Failed to fetch current user info", err);
|
||||
}
|
||||
});
|
||||
fetchSessions();
|
||||
Promise.allSettled([
|
||||
getAdminOIDCConfig()
|
||||
.then((res) => {
|
||||
if (res) setOidcConfig(res);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!err.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchOidcConfig"));
|
||||
}
|
||||
}),
|
||||
getUserInfo()
|
||||
.then((info) => {
|
||||
if (info) {
|
||||
setCurrentUser({
|
||||
id: info.userId,
|
||||
username: info.username,
|
||||
is_admin: info.is_admin,
|
||||
is_oidc: info.is_oidc,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!err?.message?.includes("No server configured")) {
|
||||
console.warn("Failed to fetch current user info", err);
|
||||
}
|
||||
}),
|
||||
getSessions()
|
||||
.then((data) => setSessions(data.sessions || []))
|
||||
.catch((err) => {
|
||||
if (!err?.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchSessions"));
|
||||
}
|
||||
}),
|
||||
]).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -332,6 +344,7 @@ export function AdminSettings({
|
||||
style={wrapperStyle}
|
||||
className="bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden"
|
||||
>
|
||||
<SimpleLoader visible={loading} message={t("common.loading")} />
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t("admin.title")}</h1>
|
||||
@@ -391,6 +404,13 @@ export function AdminSettings({
|
||||
<Database className="h-4 w-4" />
|
||||
{t("admin.databaseSecurity")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="api-keys"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
{t("admin.apiKeys.tabLabel")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="registration" className="space-y-6">
|
||||
@@ -441,7 +461,11 @@ export function AdminSettings({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-6">
|
||||
<DatabaseSecurityTab currentUser={currentUser} />
|
||||
<DatabaseSecurityTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="api-keys" className="space-y-6">
|
||||
<ApiKeysTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -459,7 +483,6 @@ export function AdminSettings({
|
||||
user={selectedUserForEdit}
|
||||
currentUser={currentUser}
|
||||
onSuccess={handleEditUserSuccess}
|
||||
allowPasswordLogin={allowPasswordLogin}
|
||||
/>
|
||||
|
||||
<LinkAccountDialog
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
Plus,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
Key,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -32,7 +30,6 @@ import {
|
||||
removeRoleFromUser,
|
||||
makeUserAdmin,
|
||||
removeAdminStatus,
|
||||
initiatePasswordReset,
|
||||
revokeAllUserSessions,
|
||||
deleteUser,
|
||||
type UserRole,
|
||||
@@ -53,7 +50,6 @@ interface UserEditDialogProps {
|
||||
user: User | null;
|
||||
currentUser: { id: string; username: string } | null;
|
||||
onSuccess: () => void;
|
||||
allowPasswordLogin: boolean;
|
||||
}
|
||||
|
||||
export function UserEditDialog({
|
||||
@@ -62,13 +58,11 @@ export function UserEditDialog({
|
||||
user,
|
||||
currentUser,
|
||||
onSuccess,
|
||||
allowPasswordLogin,
|
||||
}: UserEditDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [adminLoading, setAdminLoading] = useState(false);
|
||||
const [passwordResetLoading, setPasswordResetLoading] = useState(false);
|
||||
const [sessionLoading, setSessionLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [rolesLoading, setRolesLoading] = useState(false);
|
||||
@@ -160,42 +154,6 @@ export function UserEditDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordReset = async () => {
|
||||
if (!user) return;
|
||||
|
||||
const userToReset = user;
|
||||
onOpenChange(false);
|
||||
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("admin.resetUserPassword"),
|
||||
description: `${t("admin.passwordResetWarning")} (${userToReset.username})`,
|
||||
confirmText: t("admin.resetUserPassword"),
|
||||
cancelText: t("common.cancel"),
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordResetLoading(true);
|
||||
try {
|
||||
await initiatePasswordReset(userToReset.username);
|
||||
toast.success(
|
||||
t("admin.passwordResetInitiated", { username: userToReset.username }),
|
||||
);
|
||||
onSuccess();
|
||||
onOpenChange(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to reset password:", error);
|
||||
toast.error(t("admin.failedToResetPassword"));
|
||||
onOpenChange(true);
|
||||
} finally {
|
||||
setPasswordResetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignRole = async (roleId: number) => {
|
||||
if (!user) return;
|
||||
|
||||
@@ -342,9 +300,6 @@ export function UserEditDialog({
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const showPasswordReset =
|
||||
allowPasswordLogin && (user.passwordHash || !user.isOidc);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl bg-canvas border-2 border-edge">
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover.tsx";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
getUserList,
|
||||
type ApiKey,
|
||||
type CreatedApiKey,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
function UserCombobox({
|
||||
users,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
users: UserOption[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selected = users.find((u) => u.id === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between font-normal"
|
||||
disabled={disabled}
|
||||
>
|
||||
{selected ? selected.username : t("admin.apiKeys.selectUser")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("admin.apiKeys.searchUsers")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("admin.apiKeys.noUsersFound")}</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-y-auto thin-scrollbar">
|
||||
{users.map((user) => (
|
||||
<CommandItem
|
||||
key={user.id}
|
||||
value={user.username}
|
||||
onSelect={() => {
|
||||
onChange(user.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === user.id ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{user.username}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateApiKeyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState("");
|
||||
const [selectedUserId, setSelectedUserId] = React.useState("");
|
||||
const [expiresAt, setExpiresAt] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
const [users, setUsers] = React.useState<UserOption[]>([]);
|
||||
const [createdKey, setCreatedKey] = React.useState<CreatedApiKey | null>(
|
||||
null,
|
||||
);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
if (createdKey) return;
|
||||
|
||||
setUsersLoading(true);
|
||||
getUserList()
|
||||
.then((res) =>
|
||||
setUsers(
|
||||
res.users.map((u) => ({
|
||||
id: (u as unknown as { id: string }).id,
|
||||
username: u.username,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => toast.error(t("admin.failedToFetchUsers")))
|
||||
.finally(() => setUsersLoading(false));
|
||||
}, [open]);
|
||||
|
||||
const handleClose = () => {
|
||||
setCreatedKey(null);
|
||||
setName("");
|
||||
setSelectedUserId("");
|
||||
setExpiresAt("");
|
||||
setCopied(false);
|
||||
onOpenChange(false);
|
||||
onCreated();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("admin.apiKeys.nameRequired"));
|
||||
return;
|
||||
}
|
||||
if (!selectedUserId) {
|
||||
toast.error(t("admin.apiKeys.userRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await createApiKey(
|
||||
name.trim(),
|
||||
selectedUserId,
|
||||
expiresAt || undefined,
|
||||
);
|
||||
setCreatedKey(result);
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { error?: string } } };
|
||||
toast.error(
|
||||
e?.response?.data?.error || t("admin.apiKeys.failedToCreate"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!createdKey) return;
|
||||
await navigator.clipboard.writeText(createdKey.token);
|
||||
setCopied(true);
|
||||
toast.success(t("admin.apiKeys.tokenCopied"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
{createdKey
|
||||
? t("admin.apiKeys.keyCreated")
|
||||
: t("admin.apiKeys.createApiKey")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{createdKey
|
||||
? t("admin.apiKeys.keyCreatedDescription")
|
||||
: t("admin.apiKeys.createApiKeyDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!createdKey ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("admin.apiKeys.keyName")}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("admin.apiKeys.keyNamePlaceholder")}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("admin.apiKeys.scopedUser")}</Label>
|
||||
{usersLoading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.loading")}
|
||||
</p>
|
||||
) : (
|
||||
<UserCombobox
|
||||
users={users}
|
||||
value={selectedUserId}
|
||||
onChange={setSelectedUserId}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("admin.apiKeys.expiresAt")}{" "}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
({t("admin.apiKeys.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={expiresAt}
|
||||
onChange={(e) => setExpiresAt(e.target.value)}
|
||||
disabled={loading}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.apiKeys.expiresAtHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<Alert className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-900 dark:text-yellow-200">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||
<AlertTitle>{t("admin.apiKeys.copyWarningTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("admin.apiKeys.copyWarningDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("admin.apiKeys.apiKey")}</Label>
|
||||
<div className="flex gap-2 items-start">
|
||||
<code className="flex-1 block rounded bg-muted px-3 py-2 text-xs font-mono break-all border border-edge">
|
||||
{createdKey.token}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{!createdKey ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={loading || usersLoading}>
|
||||
{loading
|
||||
? t("admin.apiKeys.creating")
|
||||
: t("admin.apiKeys.createApiKey")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={handleClose}>{t("common.done")}</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiKeysTab(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const [keys, setKeys] = React.useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
|
||||
|
||||
const fetchKeys = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getApiKeys();
|
||||
setKeys(data.apiKeys);
|
||||
} catch {
|
||||
toast.error(t("admin.apiKeys.failedToFetch"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchKeys();
|
||||
}, [fetchKeys]);
|
||||
|
||||
const handleDelete = (keyId: string, keyName: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.apiKeys.confirmRevoke", { name: keyName }),
|
||||
async () => {
|
||||
try {
|
||||
await deleteApiKey(keyId);
|
||||
toast.success(t("admin.apiKeys.revokedSuccessfully"));
|
||||
fetchKeys();
|
||||
} catch {
|
||||
toast.error(t("admin.apiKeys.failedToRevoke"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return t("admin.apiKeys.never");
|
||||
const d = new Date(iso);
|
||||
return (
|
||||
d.toLocaleDateString() +
|
||||
" " +
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
);
|
||||
};
|
||||
|
||||
const isExpired = (expiresAt: string | null) =>
|
||||
expiresAt ? new Date(expiresAt) < new Date() : false;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{t("admin.apiKeys.title")}</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() =>
|
||||
window.open("https://docs.termix.site/api-keys", "_blank")
|
||||
}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={fetchKeys}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("h-4 w-4 mr-1", loading && "animate-spin")}
|
||||
/>
|
||||
{loading ? t("admin.loading") : t("admin.refresh")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("admin.apiKeys.createApiKey")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && keys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.loading")}
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.apiKeys.noKeys")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.apiKeys.name")}</TableHead>
|
||||
<TableHead>{t("admin.user")}</TableHead>
|
||||
<TableHead>{t("admin.apiKeys.prefix")}</TableHead>
|
||||
<TableHead>{t("admin.created")}</TableHead>
|
||||
<TableHead>{t("admin.expires")}</TableHead>
|
||||
<TableHead>{t("admin.apiKeys.lastUsed")}</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{keys.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="px-4 font-medium">
|
||||
{key.name}
|
||||
{!key.isActive && (
|
||||
<Badge variant="destructive" className="ml-2 text-xs">
|
||||
{t("admin.revoked")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{key.username || key.userId}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||
{key.tokenPrefix}…
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(key.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm">
|
||||
<span
|
||||
className={
|
||||
isExpired(key.expiresAt)
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{formatDate(key.expiresAt)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(key.lastUsedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title={t("admin.apiKeys.revokeKey")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<CreateApiKeyDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onCreated={fetchKeys}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,15 +6,7 @@ import { toast } from "sonner";
|
||||
import { isElectron } from "@/ui/main-axios.ts";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
|
||||
interface DatabaseSecurityTabProps {
|
||||
currentUser: {
|
||||
is_oidc: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function DatabaseSecurityTab({
|
||||
currentUser,
|
||||
}: DatabaseSecurityTabProps): React.ReactElement {
|
||||
export function DatabaseSecurityTab(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [exportLoading, setExportLoading] = React.useState(false);
|
||||
@@ -42,12 +34,6 @@ export function DatabaseSecurityTab({
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (isElectron()) {
|
||||
const token = localStorage.getItem("jwt");
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
@@ -110,17 +96,8 @@ export function DatabaseSecurityTab({
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
|
||||
const importHeaders: Record<string, string> = {};
|
||||
if (isElectron()) {
|
||||
const token = localStorage.getItem("jwt");
|
||||
if (token) {
|
||||
importHeaders["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: importHeaders,
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ export function GeneralSettingsTab({
|
||||
const [logLevel, setLogLevel] = React.useState("info");
|
||||
const [logLevelLoading, setLogLevelLoading] = React.useState(false);
|
||||
|
||||
const [sessionTimeoutHours, setSessionTimeoutHours] = React.useState(24);
|
||||
const [, setSessionTimeoutHours] = React.useState(24);
|
||||
const [sessionTimeoutInput, setSessionTimeoutInput] = React.useState("24");
|
||||
const [sessionTimeoutLoading, setSessionTimeoutLoading] =
|
||||
React.useState(false);
|
||||
|
||||
@@ -108,7 +108,7 @@ export function RolesTab(): React.ReactElement {
|
||||
|
||||
setRoleDialogOpen(false);
|
||||
loadRoles();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("rbac.failedToSaveRole"));
|
||||
}
|
||||
};
|
||||
@@ -129,7 +129,7 @@ export function RolesTab(): React.ReactElement {
|
||||
await deleteRole(role.id);
|
||||
toast.success(t("rbac.roleDeletedSuccessfully"));
|
||||
loadRoles();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("rbac.failedToDeleteRole"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,11 +12,7 @@ import { Monitor, Smartphone, Globe, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getCookie,
|
||||
revokeSession,
|
||||
revokeAllUserSessions,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { revokeSession, revokeAllUserSessions } from "@/ui/main-axios.ts";
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
@@ -27,8 +23,8 @@ interface Session {
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastActiveAt: string;
|
||||
jwtToken: string;
|
||||
isRevoked?: boolean;
|
||||
isCurrentSession?: boolean;
|
||||
}
|
||||
|
||||
interface SessionManagementTabProps {
|
||||
@@ -46,9 +42,9 @@ export function SessionManagementTab({
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const handleRevokeSession = async (sessionId: string) => {
|
||||
const currentJWT = getCookie("jwt");
|
||||
const currentSession = sessions.find((s) => s.jwtToken === currentJWT);
|
||||
const isCurrentSession = currentSession?.id === sessionId;
|
||||
const isCurrentSession = sessions.some(
|
||||
(session) => session.id === sessionId && session.isCurrentSession,
|
||||
);
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmRevokeSession"),
|
||||
|
||||
@@ -8,13 +8,12 @@ import {
|
||||
} from "@/components/ui/command.tsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import { Kbd, KbdKey, KbdSeparator } from "@/components/ui/kbd";
|
||||
import {
|
||||
Key,
|
||||
Server,
|
||||
Settings,
|
||||
User,
|
||||
Github,
|
||||
Terminal,
|
||||
Monitor,
|
||||
Eye,
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BiMoney, BiSupport } from "react-icons/bi";
|
||||
import { BsDiscord } from "react-icons/bs";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import { GrUpdate } from "react-icons/gr";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import {
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
logActivity,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { RecentActivityItem } from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -76,7 +75,7 @@ interface SSHHost {
|
||||
domain?: string;
|
||||
security?: string;
|
||||
ignoreCert?: boolean;
|
||||
guacamoleConfig?: any;
|
||||
guacamoleConfig?: unknown;
|
||||
showTerminalInSidebar?: boolean;
|
||||
showFileManagerInSidebar?: boolean;
|
||||
showTunnelInSidebar?: boolean;
|
||||
@@ -84,6 +83,28 @@ interface SSHHost {
|
||||
showServerStatsInSidebar?: boolean;
|
||||
}
|
||||
|
||||
function shouldShowMetrics(host: SSHHost): boolean {
|
||||
try {
|
||||
const statsConfig = host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
: DEFAULT_STATS_CONFIG;
|
||||
return statsConfig.metricsEnabled !== false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function hasTunnelConnections(host: SSHHost): boolean {
|
||||
try {
|
||||
const tunnelConnections = Array.isArray(host.tunnelConnections)
|
||||
? host.tunnelConnections
|
||||
: JSON.parse(host.tunnelConnections as string);
|
||||
return Array.isArray(tunnelConnections) && tunnelConnections.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
@@ -306,9 +327,6 @@ export function CommandPalette({
|
||||
};
|
||||
|
||||
const handleHostEditClick = (host: SSHHost) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "ssh_manager",
|
||||
title: t("commandPalette.hostManager"),
|
||||
@@ -391,32 +409,10 @@ export function CommandPalette({
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
|
||||
let shouldShowMetrics = true;
|
||||
try {
|
||||
const statsConfig = host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
: DEFAULT_STATS_CONFIG;
|
||||
shouldShowMetrics = statsConfig.metricsEnabled !== false;
|
||||
} catch {
|
||||
shouldShowMetrics = true;
|
||||
}
|
||||
|
||||
const isSSH =
|
||||
!host.connectionType || host.connectionType === "ssh";
|
||||
|
||||
let hasTunnelConnections = false;
|
||||
try {
|
||||
const tunnelConnections = Array.isArray(
|
||||
host.tunnelConnections,
|
||||
)
|
||||
? host.tunnelConnections
|
||||
: JSON.parse(host.tunnelConnections as string);
|
||||
hasTunnelConnections =
|
||||
Array.isArray(tunnelConnections) &&
|
||||
tunnelConnections.length > 0;
|
||||
} catch {
|
||||
hasTunnelConnections = false;
|
||||
}
|
||||
const showMetrics = shouldShowMetrics(host);
|
||||
const hasTunnels = hasTunnelConnections(host);
|
||||
|
||||
const visibleButtons = [
|
||||
host.enableTerminal && (host.showTerminalInSidebar ?? true),
|
||||
@@ -425,13 +421,13 @@ export function CommandPalette({
|
||||
(host.showFileManagerInSidebar ?? false),
|
||||
isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
hasTunnels &&
|
||||
(host.showTunnelInSidebar ?? false),
|
||||
isSSH &&
|
||||
host.enableDocker &&
|
||||
(host.showDockerInSidebar ?? false),
|
||||
isSSH &&
|
||||
shouldShowMetrics &&
|
||||
showMetrics &&
|
||||
(host.showServerStatsInSidebar ?? false),
|
||||
].filter(Boolean).length;
|
||||
|
||||
@@ -493,7 +489,7 @@ export function CommandPalette({
|
||||
|
||||
{isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
hasTunnels &&
|
||||
(host.showTunnelInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -523,7 +519,7 @@ export function CommandPalette({
|
||||
)}
|
||||
|
||||
{isSSH &&
|
||||
shouldShowMetrics &&
|
||||
showMetrics &&
|
||||
(host.showServerStatsInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -580,7 +576,7 @@ export function CommandPalette({
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSSH &&
|
||||
shouldShowMetrics &&
|
||||
showMetrics &&
|
||||
!(host.showServerStatsInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
@@ -613,7 +609,7 @@ export function CommandPalette({
|
||||
)}
|
||||
{isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
hasTunnels &&
|
||||
!(host.showTunnelInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
@@ -666,7 +662,7 @@ export function CommandPalette({
|
||||
)}
|
||||
<CommandGroup heading={t("commandPalette.links")}>
|
||||
<CommandItem onSelect={handleGitHub}>
|
||||
<Github />
|
||||
<FaGithub />
|
||||
<span>{t("commandPalette.github")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleSupport}>
|
||||
@@ -686,10 +682,11 @@ export function CommandPalette({
|
||||
<div className="border-t border-edge px-4 py-2 bg-hover/50 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("commandPalette.press")}</span>
|
||||
<KbdGroup>
|
||||
<Kbd>Shift</Kbd>
|
||||
<Kbd>Shift</Kbd>
|
||||
</KbdGroup>
|
||||
<Kbd>
|
||||
<KbdKey>Shift</KbdKey>
|
||||
<KbdSeparator />
|
||||
<KbdKey>Shift</KbdKey>
|
||||
</Kbd>
|
||||
<span>{t("commandPalette.toToggle")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
|
||||
import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
getUserInfo,
|
||||
getDatabaseHealth,
|
||||
getCookie,
|
||||
getUptime,
|
||||
getVersionInfo,
|
||||
getSSHHosts,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
sendMetricsHeartbeat,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
isCurrentAuthInvalidationError,
|
||||
type RecentActivityItem,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
@@ -64,11 +64,11 @@ export function Dashboard({
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [, setUsername] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [dbError, setDbError] = useState<string | null>(initialDbError);
|
||||
const [, setDbError] = useState<string | null>(initialDbError);
|
||||
|
||||
const [uptime, setUptime] = useState<string>("0d 0h 0m");
|
||||
const [versionStatus, setVersionStatus] = useState<
|
||||
"up_to_date" | "requires_update"
|
||||
"up_to_date" | "requires_update" | "beta"
|
||||
>("up_to_date");
|
||||
const [versionText, setVersionText] = useState<string>("");
|
||||
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
|
||||
@@ -93,7 +93,7 @@ export function Dashboard({
|
||||
);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
|
||||
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
|
||||
const { addTab, setCurrentTab, tabs: tabList } = useTabs();
|
||||
const {
|
||||
layout,
|
||||
loading: preferencesLoading,
|
||||
@@ -102,12 +102,12 @@ export function Dashboard({
|
||||
} = useDashboardPreferences(loggedIn);
|
||||
|
||||
let sidebarState: "expanded" | "collapsed" = "expanded";
|
||||
let sidebarAvailable = false;
|
||||
try {
|
||||
const sidebar = useSidebar();
|
||||
sidebarState = sidebar.state;
|
||||
sidebarAvailable = true;
|
||||
} catch {}
|
||||
} catch {
|
||||
// Sidebar context is not available on every dashboard mount path.
|
||||
}
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
@@ -120,40 +120,36 @@ export function Dashboard({
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
if (getCookie("jwt")) {
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isCurrentAuthInvalidationError(err)) {
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
console.warn("Session expired - please log in again");
|
||||
setDbError("Session expired - please log in again");
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
});
|
||||
|
||||
getDatabaseHealth()
|
||||
.then(() => {
|
||||
console.warn("Session expired - please log in again");
|
||||
setDbError("Session expired - please log in again");
|
||||
} else {
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError(
|
||||
"Could not connect to the database. Please try again later.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
getDatabaseHealth()
|
||||
.then(() => {
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError(
|
||||
"Could not connect to the database. Please try again later.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
@@ -173,7 +169,8 @@ export function Dashboard({
|
||||
setVersionText(`v${versionInfo.localVersion}`);
|
||||
if (
|
||||
versionInfo.status === "up_to_date" ||
|
||||
versionInfo.status === "requires_update"
|
||||
versionInfo.status === "requires_update" ||
|
||||
versionInfo.status === "beta"
|
||||
) {
|
||||
setVersionStatus(versionInfo.status);
|
||||
}
|
||||
@@ -602,7 +599,6 @@ export function Dashboard({
|
||||
setUserId={setUserId}
|
||||
loggedIn={loggedIn}
|
||||
authLoading={authLoading}
|
||||
dbError={dbError}
|
||||
setDbError={setDbError}
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
/>
|
||||
@@ -636,7 +632,7 @@ export function Dashboard({
|
||||
<div className="flex flex-row gap-3 flex-wrap min-w-0">
|
||||
<div className="flex flex-col items-center gap-4 justify-center mr-5 min-w-0 shrink">
|
||||
<p className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
Press <Kbd>LShift</Kbd> twice to open the command palette
|
||||
Press <Kbd>L Shift</Kbd> twice to open the command palette
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from "@/components/ui/sheet.tsx";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet.tsx";
|
||||
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BookOpen, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface UpdateLogProps extends React.ComponentProps<"div"> {
|
||||
loggedIn: boolean;
|
||||
@@ -48,8 +41,9 @@ interface RSSResponse {
|
||||
}
|
||||
|
||||
interface VersionResponse {
|
||||
status: "up_to_date" | "requires_update";
|
||||
status: "up_to_date" | "requires_update" | "beta";
|
||||
version: string;
|
||||
localVersion?: string;
|
||||
latest_release: {
|
||||
name: string;
|
||||
published_at: string;
|
||||
@@ -136,6 +130,19 @@ export function UpdateLog({ loggedIn }: UpdateLogProps) {
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{versionInfo && versionInfo.status === "beta" && (
|
||||
<Alert className="bg-elevated border-edge text-foreground mb-3">
|
||||
<AlertTitle className="text-foreground">
|
||||
{t("versionCheck.betaVersion")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-foreground-secondary">
|
||||
{t("versionCheck.betaVersionDesc", {
|
||||
current: versionInfo.localVersion,
|
||||
latest: versionInfo.version,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
|
||||
@@ -71,7 +71,7 @@ export function AlertCard({
|
||||
alert,
|
||||
onDismiss,
|
||||
onClose,
|
||||
}: AlertCardProps): React.ReactElement {
|
||||
}: AlertCardProps): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!alert) {
|
||||
@@ -83,18 +83,6 @@ export function AlertCard({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const formatExpiryDate = (expiryString: string) => {
|
||||
const expiryDate = new Date(expiryString);
|
||||
const now = new Date();
|
||||
const diffTime = expiryDate.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return t("common.expired");
|
||||
if (diffDays === 0) return t("common.expiresToday");
|
||||
if (diffDays === 1) return t("common.expiresTomorrow");
|
||||
return t("common.expiresInDays", { days: diffDays });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto">
|
||||
<CardHeader className="pb-3">
|
||||
@@ -123,9 +111,6 @@ export function AlertCard({
|
||||
{alert.type}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatExpiryDate(alert.expiresAt)}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-4">
|
||||
@@ -136,7 +121,7 @@ export function AlertCard({
|
||||
<CardFooter className="flex items-center justify-between pt-0">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDismiss}>
|
||||
Dismiss
|
||||
{t("common.dismiss")}
|
||||
</Button>
|
||||
{alert.actionUrl && alert.actionText && (
|
||||
<Button
|
||||
|
||||
@@ -144,10 +144,10 @@ export function AlertManager({
|
||||
disabled={currentAlertIndex === 0}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
Previous
|
||||
{t("common.previous")}
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{currentAlertIndex + 1} of {alerts.length}
|
||||
{currentAlertIndex + 1} {t("common.of")} {alerts.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -156,7 +156,7 @@ export function AlertManager({
|
||||
disabled={currentAlertIndex === alerts.length - 1}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
Next
|
||||
{t("common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
getNetworkTopology,
|
||||
saveNetworkTopology,
|
||||
type SSHHostWithStatus,
|
||||
type NetworkTopologyEdge,
|
||||
type NetworkTopologyNode,
|
||||
} from "@/ui/main-axios";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -34,7 +36,6 @@ import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Move3D,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
RotateCw,
|
||||
@@ -46,7 +47,6 @@ import {
|
||||
Edit,
|
||||
FolderInput,
|
||||
FolderMinus,
|
||||
Settings2,
|
||||
Terminal,
|
||||
ArrowUp,
|
||||
NetworkIcon,
|
||||
@@ -104,13 +104,15 @@ interface NetworkGraphCardProps {
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
|
||||
|
||||
export function NetworkGraphCard({
|
||||
embedded = true,
|
||||
}: NetworkGraphCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { addTab } = useTabs();
|
||||
|
||||
const [elements, setElements] = useState<any[]>([]);
|
||||
const [elements, setElements] = useState<NetworkElement[]>([]);
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [hostMap, setHostMap] = useState<HostMap>({});
|
||||
const hostMapRef = useRef<HostMap>({});
|
||||
@@ -205,8 +207,8 @@ export function NetworkGraphCard({
|
||||
});
|
||||
setHostMap(newHostMap);
|
||||
|
||||
let nodes: any[] = [];
|
||||
let edges: any[] = [];
|
||||
let nodes: NetworkTopologyNode[] = [];
|
||||
let edges: NetworkTopologyEdge[] = [];
|
||||
|
||||
try {
|
||||
const topologyData = await getNetworkTopology();
|
||||
@@ -215,7 +217,7 @@ export function NetworkGraphCard({
|
||||
topologyData.nodes &&
|
||||
Array.isArray(topologyData.nodes)
|
||||
) {
|
||||
nodes = topologyData.nodes.map((node: any) => {
|
||||
nodes = topologyData.nodes.map((node) => {
|
||||
const host = newHostMap[node.data.id];
|
||||
return {
|
||||
data: {
|
||||
@@ -232,12 +234,12 @@ export function NetworkGraphCard({
|
||||
});
|
||||
edges = topologyData.edges || [];
|
||||
}
|
||||
} catch (topologyError) {
|
||||
} catch {
|
||||
console.warn("Starting with empty topology");
|
||||
}
|
||||
|
||||
const nodeIds = new Set(nodes.map((n: any) => n.data.id));
|
||||
const validEdges = edges.filter((edge: any) => {
|
||||
const nodeIds = new Set(nodes.map((n) => n.data.id));
|
||||
const validEdges = edges.filter((edge) => {
|
||||
const sourceExists = nodeIds.has(edge.data.source);
|
||||
const targetExists = nodeIds.has(edge.data.target);
|
||||
return sourceExists && targetExists;
|
||||
@@ -315,7 +317,10 @@ export function NetworkGraphCard({
|
||||
useEffect(() => {
|
||||
if (!cyRef.current || loading || elements.length === 0) return;
|
||||
const hasPositions = elements.some(
|
||||
(el: any) => el.position && (el.position.x !== 0 || el.position.y !== 0),
|
||||
(el) =>
|
||||
"position" in el &&
|
||||
el.position &&
|
||||
(el.position.x !== 0 || el.position.y !== 0),
|
||||
);
|
||||
|
||||
if (!hasPositions) {
|
||||
@@ -633,7 +638,7 @@ export function NetworkGraphCard({
|
||||
setElements([...cyRef.current.elements().jsons()]);
|
||||
forceUpdate();
|
||||
setShowAddNodeDialog(false);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError(t("networkGraph.failedToAddNode"));
|
||||
}
|
||||
};
|
||||
@@ -891,7 +896,7 @@ export function NetworkGraphCard({
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError(t("networkGraph.invalidFile"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Monitor,
|
||||
Eye,
|
||||
MessagesSquare,
|
||||
Network,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { type RecentActivityItem } from "@/ui/main-axios";
|
||||
|
||||
@@ -14,7 +14,7 @@ import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog";
|
||||
interface ServerOverviewCardProps {
|
||||
loggedIn: boolean;
|
||||
versionText: string;
|
||||
versionStatus: "up_to_date" | "requires_update";
|
||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||
uptime: string;
|
||||
dbHealth: "healthy" | "error";
|
||||
totalServers: number;
|
||||
@@ -61,11 +61,13 @@ export function ServerOverviewCard({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
|
||||
className={`ml-2 text-sm border-1 border-edge ${versionStatus === "up_to_date" ? "text-green-400" : versionStatus === "beta" ? "text-blue-400" : "text-yellow-400"}`}
|
||||
>
|
||||
{versionStatus === "up_to_date"
|
||||
? t("dashboard.upToDate")
|
||||
: t("dashboard.updateAvailable")}
|
||||
: versionStatus === "beta"
|
||||
? t("dashboard.beta")
|
||||
: t("dashboard.updateAvailable")}
|
||||
</Button>
|
||||
<UpdateLog loggedIn={loggedIn} />
|
||||
</>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function useDashboardPreferences(enabled: boolean = true) {
|
||||
} else {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setLayout(DEFAULT_LAYOUT);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import React from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
|
||||
import {
|
||||
connectDockerSession,
|
||||
@@ -33,6 +27,7 @@ import {
|
||||
useConnectionLog,
|
||||
} from "@/ui/desktop/navigation/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ui/desktop/navigation/connection-log/ConnectionLog.tsx";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
|
||||
interface DockerManagerProps {
|
||||
hostConfig?: SSHHost;
|
||||
@@ -43,10 +38,11 @@ interface DockerManagerProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
type ConnectionLogInput = Omit<LogEntry, "id" | "timestamp">;
|
||||
|
||||
interface DockerConnectionError {
|
||||
message?: string;
|
||||
connectionLogs?: ConnectionLogInput[];
|
||||
}
|
||||
|
||||
function DockerManagerInner({
|
||||
@@ -76,7 +72,6 @@ function DockerManagerInner({
|
||||
string | null
|
||||
>(null);
|
||||
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||
const [activeTab, setActiveTab] = React.useState("containers");
|
||||
const [dockerValidation, setDockerValidation] =
|
||||
React.useState<DockerValidation | null>(null);
|
||||
const [isValidating, setIsValidating] = React.useState(false);
|
||||
@@ -251,18 +246,19 @@ function DockerManagerInner({
|
||||
logDockerActivity();
|
||||
setTimeout(() => clearLogs(), 1000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
const dockerError = error as DockerConnectionError;
|
||||
setIsConnecting(false);
|
||||
setIsValidating(false);
|
||||
setHasConnectionError(true);
|
||||
|
||||
if (error?.connectionLogs) {
|
||||
setLogs(error.connectionLogs);
|
||||
if (Array.isArray(dockerError.connectionLogs)) {
|
||||
setLogs(dockerError.connectionLogs);
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error?.message || t("docker.connectionFailed"),
|
||||
message: dockerError.message || t("docker.connectionFailed"),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
@@ -302,7 +298,7 @@ function DockerManagerInner({
|
||||
try {
|
||||
const data = await listDockerContainers(sessionId, true);
|
||||
setContainers(data);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Silently handle polling errors
|
||||
}
|
||||
}, [sessionId]);
|
||||
@@ -319,7 +315,7 @@ function DockerManagerInner({
|
||||
if (!cancelled) {
|
||||
setContainers(data);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Silently handle polling errors
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@@ -661,7 +657,7 @@ function DockerManagerInner({
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 relative">
|
||||
{viewMode === "list" ? (
|
||||
<div className="h-full min-h-0 px-4 py-4">
|
||||
<div className="h-full min-h-0 px-4 pt-4">
|
||||
{sessionId ? (
|
||||
isLoadingContainers && containers.length === 0 ? (
|
||||
<SimpleLoader
|
||||
|
||||
@@ -17,12 +17,11 @@ import { getBasePath } from "@/lib/base-path";
|
||||
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { getCookie, isElectron } from "@/ui/main-axios.ts";
|
||||
import { isElectron } from "@/ui/main-axios.ts";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ConsoleTerminalProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
containerState: string;
|
||||
@@ -30,7 +29,6 @@ interface ConsoleTerminalProps {
|
||||
}
|
||||
|
||||
export function ConsoleTerminal({
|
||||
sessionId,
|
||||
containerId,
|
||||
containerName,
|
||||
containerState,
|
||||
@@ -63,18 +61,32 @@ export function ConsoleTerminal({
|
||||
terminal.options.fontSize = 14;
|
||||
terminal.options.fontFamily = "monospace";
|
||||
|
||||
const readTextFromClipboard = async (): Promise<string> => {
|
||||
if (window.electronClipboard) {
|
||||
return window.electronClipboard.readText();
|
||||
}
|
||||
return navigator.clipboard.readText();
|
||||
};
|
||||
|
||||
const writeTextToClipboard = async (text: string): Promise<void> => {
|
||||
if (window.electronClipboard) {
|
||||
await window.electronClipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => {
|
||||
if (e.type !== "keydown") return true;
|
||||
|
||||
if (
|
||||
((e.ctrlKey && !e.altKey && !e.metaKey) ||
|
||||
(e.metaKey && !e.ctrlKey && !e.altKey)) &&
|
||||
((e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) ||
|
||||
(e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey)) &&
|
||||
e.key.toLowerCase() === "v"
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
readTextFromClipboard()
|
||||
.then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
})
|
||||
@@ -96,7 +108,7 @@ export function ConsoleTerminal({
|
||||
e.stopPropagation();
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
navigator.clipboard.writeText(selection).catch(() => {
|
||||
writeTextToClipboard(selection).catch(() => {
|
||||
toast.error(t("terminal.clipboardWriteFailed"));
|
||||
});
|
||||
terminal.clearSelection();
|
||||
@@ -105,23 +117,18 @@ export function ConsoleTerminal({
|
||||
}
|
||||
|
||||
if (
|
||||
((e.ctrlKey &&
|
||||
e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key.toLowerCase() === "c") ||
|
||||
(e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "Insert")) &&
|
||||
e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
e.key === "Insert" &&
|
||||
terminal.hasSelection()
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
navigator.clipboard.writeText(selection).catch(() => {
|
||||
writeTextToClipboard(selection).catch(() => {
|
||||
toast.error(t("terminal.clipboardWriteFailed"));
|
||||
});
|
||||
}
|
||||
@@ -137,8 +144,7 @@ export function ConsoleTerminal({
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
readTextFromClipboard()
|
||||
.then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
})
|
||||
@@ -192,20 +198,24 @@ export function ConsoleTerminal({
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch (error) {}
|
||||
} catch {
|
||||
// Best-effort disconnect during cleanup.
|
||||
}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
terminal.dispose();
|
||||
};
|
||||
}, [terminal]);
|
||||
}, [terminal, t]);
|
||||
|
||||
const disconnect = React.useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch (error) {}
|
||||
} catch {
|
||||
// Best-effort disconnect.
|
||||
}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
@@ -213,9 +223,11 @@ export function ConsoleTerminal({
|
||||
if (terminal) {
|
||||
try {
|
||||
terminal.clear();
|
||||
} catch (error) {}
|
||||
} catch {
|
||||
// Terminal clear can fail after disposal.
|
||||
}
|
||||
}
|
||||
}, [terminal, t]);
|
||||
}, [terminal]);
|
||||
|
||||
const connect = React.useCallback(() => {
|
||||
if (!terminal || containerState !== "running") {
|
||||
@@ -226,15 +238,6 @@ export function ConsoleTerminal({
|
||||
setIsConnecting(true);
|
||||
|
||||
try {
|
||||
const token = isElectron()
|
||||
? localStorage.getItem("jwt")
|
||||
: getCookie("jwt");
|
||||
if (!token) {
|
||||
toast.error(t("docker.authenticationRequired"));
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
@@ -410,7 +413,9 @@ export function ConsoleTerminal({
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch (error) {}
|
||||
} catch {
|
||||
// Best-effort disconnect during cleanup.
|
||||
}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ export function ContainerDetail({
|
||||
className="flex-1 overflow-hidden px-3 pb-3 mt-3"
|
||||
>
|
||||
<ConsoleTerminal
|
||||
sessionId={sessionId}
|
||||
containerId={containerId}
|
||||
containerName={container.name}
|
||||
containerState={container.state}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function ContainerList({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0 gap-3">
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
@@ -117,7 +117,7 @@ export function ContainerList({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-auto thin-scrollbar pr-1">
|
||||
<div className="min-h-0 flex-1 overflow-auto thin-scrollbar h-fade pr-1 pb-2 pt-4">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full pb-2">
|
||||
{filteredContainers.map((container) => (
|
||||
<ContainerCard
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
useConnectionLog,
|
||||
} from "@/ui/desktop/navigation/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ui/desktop/navigation/connection-log/ConnectionLog.tsx";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
import {
|
||||
listSSHFiles,
|
||||
@@ -91,6 +92,20 @@ interface FileManagerProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
|
||||
|
||||
type SSHConnectionError = Error & {
|
||||
connectionLogs?: ConnectionLogPayload[];
|
||||
requires_totp?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
status?: string;
|
||||
reason?: "no_keyboard" | "auth_failed" | "timeout";
|
||||
};
|
||||
|
||||
interface CreateIntent {
|
||||
id: string;
|
||||
type: "file" | "directory";
|
||||
@@ -165,7 +180,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
>("no_keyboard");
|
||||
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
|
||||
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
|
||||
const [isClosing, setIsClosing] = useState<boolean>(false);
|
||||
const [hasConnectionError, setHasConnectionError] = useState<boolean>(false);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
@@ -446,11 +460,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
} catch (dirError: unknown) {
|
||||
console.error("Failed to load initial directory:", dirError);
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const sshError = error as SSHConnectionError;
|
||||
console.error("SSH connection failed:", error);
|
||||
|
||||
if (error?.connectionLogs) {
|
||||
error.connectionLogs.forEach((log: any) => {
|
||||
if (sshError.connectionLogs) {
|
||||
sshError.connectionLogs.forEach((log) => {
|
||||
addLog({
|
||||
type: log.type,
|
||||
stage: log.stage,
|
||||
@@ -458,25 +473,25 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
details: log.details,
|
||||
});
|
||||
});
|
||||
if (error.requires_totp) {
|
||||
if (sshError.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(error.sessionId || currentHost.id.toString());
|
||||
setTotpSessionId(sshError.sessionId || currentHost.id.toString());
|
||||
setTotpPrompt(
|
||||
error.prompt || t("fileManager.verificationCodePrompt"),
|
||||
sshError.prompt || t("fileManager.verificationCodePrompt"),
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (error.requires_warpgate) {
|
||||
if (sshError.requires_warpgate) {
|
||||
setWarpgateRequired(true);
|
||||
setWarpgateSessionId(error.sessionId || currentHost.id.toString());
|
||||
setWarpgateUrl(error.url || "");
|
||||
setWarpgateSecurityKey(error.securityKey || "N/A");
|
||||
setWarpgateSessionId(sshError.sessionId || currentHost.id.toString());
|
||||
setWarpgateUrl(sshError.url || "");
|
||||
setWarpgateSecurityKey(sshError.securityKey || "N/A");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (error.status === "auth_required") {
|
||||
setAuthDialogReason(error.reason || "no_keyboard");
|
||||
if (sshError.status === "auth_required") {
|
||||
setAuthDialogReason(sshError.reason || "no_keyboard");
|
||||
setShowAuthDialog(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
@@ -485,7 +500,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error?.message || t("fileManager.failedToConnect"),
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("fileManager.failedToConnect"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -539,25 +557,36 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
if (currentLoadingPathRef.current === resolvedPath) {
|
||||
const axiosError = error as {
|
||||
// ApiError has .status directly; raw axios errors have .response.status
|
||||
const apiError = error as {
|
||||
status?: number;
|
||||
code?: string;
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
needsSudo?: boolean;
|
||||
error?: string;
|
||||
sudoFailed?: boolean;
|
||||
disconnected?: boolean;
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (axiosError.response?.data?.needsSudo) {
|
||||
const httpStatus = apiError.status ?? apiError.response?.status;
|
||||
|
||||
// 409 = concurrent request already in flight — silently drop
|
||||
if (httpStatus === 409) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (apiError.response?.data?.needsSudo) {
|
||||
if (!sudoDialogOpen) {
|
||||
setPendingSudoOperation({ type: "navigate", path: resolvedPath });
|
||||
setSudoDialogOpen(true);
|
||||
}
|
||||
|
||||
if (axiosError.response.data.sudoFailed) {
|
||||
if (apiError.response.data.sudoFailed) {
|
||||
toast.error(t("fileManager.sudoAuthFailed"));
|
||||
} else {
|
||||
toast.error(t("fileManager.permissionDenied"));
|
||||
@@ -568,24 +597,50 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
console.error("Failed to load directory:", error);
|
||||
|
||||
const errorMessage =
|
||||
axiosError.response?.data?.error ||
|
||||
axiosError.message ||
|
||||
String(error);
|
||||
apiError.response?.data?.error || apiError.message || String(error);
|
||||
|
||||
if (initialLoadDoneRef.current) {
|
||||
const isConnectionError =
|
||||
// 500s from the file manager are SSH channel/session errors
|
||||
httpStatus === 500 ||
|
||||
httpStatus === 503 ||
|
||||
apiError.response?.data?.disconnected === true ||
|
||||
errorMessage?.includes("channel open failure") ||
|
||||
errorMessage?.includes("open failed") ||
|
||||
errorMessage?.includes("SSH connection not established") ||
|
||||
errorMessage?.includes("SSH session") ||
|
||||
errorMessage?.toLowerCase().includes("not connected");
|
||||
|
||||
if (isConnectionError && sshSessionId && currentHost) {
|
||||
setIsReconnecting(true);
|
||||
setIsLoading(false);
|
||||
setFiles([]);
|
||||
currentLoadingPathRef.current = "";
|
||||
|
||||
void (async () => {
|
||||
const delays = [1000, 2000, 3000, 5000, 5000];
|
||||
for (let attempt = 0; attempt < delays.length; attempt++) {
|
||||
await new Promise((r) => setTimeout(r, delays[attempt]));
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
setIsReconnecting(false);
|
||||
loadDirectory(resolvedPath);
|
||||
return;
|
||||
} catch {
|
||||
// keep retrying
|
||||
}
|
||||
}
|
||||
setIsReconnecting(false);
|
||||
handleCloseWithError(
|
||||
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
|
||||
);
|
||||
})();
|
||||
|
||||
return false;
|
||||
} else if (initialLoadDoneRef.current) {
|
||||
toast.error(
|
||||
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
errorMessage?.includes("connection") ||
|
||||
errorMessage?.includes("SSH")
|
||||
) {
|
||||
handleCloseWithError(
|
||||
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
@@ -595,7 +650,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[sshSessionId, isLoading, clearSelection, t, sudoDialogOpen],
|
||||
[sshSessionId, isLoading, clearSelection, t, sudoDialogOpen, currentHost],
|
||||
);
|
||||
|
||||
const debouncedLoadDirectory = useCallback(
|
||||
@@ -699,19 +754,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
reader.onerror = () => reject(reader.error);
|
||||
|
||||
reader.onload = () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(reader.result);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
if (typeof reader.result === "string") {
|
||||
const base64 = reader.result.split(",")[1] || "";
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to read file"));
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
await uploadSSHFile(
|
||||
@@ -1530,40 +1580,30 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
|
||||
async function ensureSSHConnection() {
|
||||
if (!sshSessionId || !currentHost || isReconnecting) return;
|
||||
if (!sshSessionId || !currentHost) return;
|
||||
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
|
||||
if (!status.connected && !isReconnecting) {
|
||||
setIsReconnecting(true);
|
||||
await connectSSH(sshSessionId, {
|
||||
hostId: currentHost.id,
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
password: currentHost.password,
|
||||
sshKey: currentHost.key,
|
||||
keyPassword: currentHost.keyPassword,
|
||||
authType: currentHost.authType,
|
||||
credentialId: currentHost.credentialId,
|
||||
userId: currentHost.userId,
|
||||
jumpHosts: currentHost.jumpHosts,
|
||||
useSocks5: currentHost.useSocks5,
|
||||
socks5Host: currentHost.socks5Host,
|
||||
socks5Port: currentHost.socks5Port,
|
||||
socks5Username: currentHost.socks5Username,
|
||||
socks5Password: currentHost.socks5Password,
|
||||
socks5ProxyChain: currentHost.socks5ProxyChain,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
handleCloseWithError(
|
||||
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsReconnecting(false);
|
||||
if (!status.connected) {
|
||||
await connectSSH(sshSessionId, {
|
||||
hostId: currentHost.id,
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
password: currentHost.password,
|
||||
sshKey: currentHost.key,
|
||||
keyPassword: currentHost.keyPassword,
|
||||
authType: currentHost.authType,
|
||||
credentialId: currentHost.credentialId,
|
||||
userId: currentHost.userId,
|
||||
jumpHosts: currentHost.jumpHosts,
|
||||
useSocks5: currentHost.useSocks5,
|
||||
socks5Host: currentHost.socks5Host,
|
||||
socks5Port: currentHost.socks5Port,
|
||||
socks5Username: currentHost.socks5Username,
|
||||
socks5Password: currentHost.socks5Password,
|
||||
socks5ProxyChain: currentHost.socks5ProxyChain,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
FileArchive,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd.tsx";
|
||||
import { Kbd, KbdKey, KbdSeparator } from "@/components/ui/kbd.tsx";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
@@ -491,11 +491,14 @@ export function FileManagerContextMenu({
|
||||
return <Kbd>{keys[0]}</Kbd>;
|
||||
}
|
||||
return (
|
||||
<KbdGroup>
|
||||
<Kbd>
|
||||
{keys.map((key, index) => (
|
||||
<Kbd key={index}>{key}</Kbd>
|
||||
<>
|
||||
<KbdKey key={`key-${index}`}>{key}</KbdKey>
|
||||
{index < keys.length - 1 && <KbdSeparator key={`sep-${index}`} />}
|
||||
</>
|
||||
))}
|
||||
</KbdGroup>
|
||||
</Kbd>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Folder,
|
||||
File,
|
||||
Star,
|
||||
Clock,
|
||||
Bookmark,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { Star, Clock, Bookmark, File, Folder } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SSHHost } from "@/types";
|
||||
import {
|
||||
@@ -22,6 +19,9 @@ import {
|
||||
removeFolderShortcut,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import FolderTree from "@/components/ui/folder.tsx";
|
||||
|
||||
// ─── Interfaces ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RecentFileData {
|
||||
id: number;
|
||||
@@ -71,6 +71,8 @@ interface FileManagerSidebarProps {
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FileManagerSidebar({
|
||||
currentHost,
|
||||
currentPath,
|
||||
@@ -80,14 +82,22 @@ export function FileManagerSidebar({
|
||||
refreshTrigger,
|
||||
}: FileManagerSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// ── Quick access state (API-backed) ──────────────────────────────────────────
|
||||
const [recentItems, setRecentItems] = useState<SidebarItem[]>([]);
|
||||
const [pinnedItems, setPinnedItems] = useState<SidebarItem[]>([]);
|
||||
const [shortcuts, setShortcuts] = useState<SidebarItem[]>([]);
|
||||
const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
||||
new Set(["root"]),
|
||||
);
|
||||
|
||||
// ── Directory tree state ──────────────────────────────────────────────────────
|
||||
const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]);
|
||||
|
||||
/**
|
||||
* Tracks which folder paths have already been lazy-loaded so we don't
|
||||
* re-fetch on every re-selection / collapse-reopen.
|
||||
*/
|
||||
const loadedFoldersRef = useRef<Set<string>>(new Set(["/"]));
|
||||
|
||||
// ── Context menu state ────────────────────────────────────────────────────────
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -100,16 +110,47 @@ export function FileManagerSidebar({
|
||||
item: null,
|
||||
});
|
||||
|
||||
// ─── Effects ──────────────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
loadQuickAccessData();
|
||||
}, [currentHost, refreshTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sshSessionId) {
|
||||
loadedFoldersRef.current = new Set(["/"]);
|
||||
loadDirectoryTree();
|
||||
}
|
||||
}, [sshSessionId]);
|
||||
|
||||
// When currentPath changes externally (grid navigation), ensure the parent
|
||||
// directory is loaded in the tree so the selection highlight can appear.
|
||||
useEffect(() => {
|
||||
if (!sshSessionId || currentPath === "/") return;
|
||||
|
||||
const parentPath =
|
||||
currentPath.substring(0, currentPath.lastIndexOf("/")) || "/";
|
||||
|
||||
const findByPath = (items: SidebarItem[]): SidebarItem | null => {
|
||||
for (const item of items) {
|
||||
if (item.path === parentPath) return item;
|
||||
if (item.children) {
|
||||
const found = findByPath(item.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parent = findByPath(directoryTree);
|
||||
if (parent && !loadedFoldersRef.current.has(parent.path)) {
|
||||
loadedFoldersRef.current.add(parent.path);
|
||||
loadSubdirectory(parent.id, parent.path);
|
||||
}
|
||||
}, [currentPath, sshSessionId]);
|
||||
|
||||
// ─── API: Quick access ────────────────────────────────────────────────────────
|
||||
|
||||
const loadQuickAccessData = async () => {
|
||||
if (!currentHost?.id) return;
|
||||
|
||||
@@ -155,114 +196,13 @@ export function FileManagerSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRecentFile = async (item: SidebarItem) => {
|
||||
if (!currentHost?.id) return;
|
||||
// ─── API: Directory tree ──────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
await removeRecentFile(currentHost.id, item.path);
|
||||
loadQuickAccessData();
|
||||
toast.success(
|
||||
t("fileManager.removedFromRecentFiles", { name: item.name }),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove recent file:", error);
|
||||
toast.error(t("fileManager.removeFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpinFile = async (item: SidebarItem) => {
|
||||
if (!currentHost?.id) return;
|
||||
|
||||
try {
|
||||
await removePinnedFile(currentHost.id, item.path);
|
||||
loadQuickAccessData();
|
||||
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
|
||||
} catch (error) {
|
||||
console.error("Failed to unpin file:", error);
|
||||
toast.error(t("fileManager.unpinFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveShortcut = async (item: SidebarItem) => {
|
||||
if (!currentHost?.id) return;
|
||||
|
||||
try {
|
||||
await removeFolderShortcut(currentHost.id, item.path);
|
||||
loadQuickAccessData();
|
||||
toast.success(t("fileManager.removedShortcut", { name: item.name }));
|
||||
} catch (error) {
|
||||
console.error("Failed to remove shortcut:", error);
|
||||
toast.error(t("fileManager.removeShortcutFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAllRecent = async () => {
|
||||
if (!currentHost?.id || recentItems.length === 0) return;
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
|
||||
);
|
||||
loadQuickAccessData();
|
||||
toast.success(t("fileManager.clearedAllRecentFiles"));
|
||||
} catch (error) {
|
||||
console.error("Failed to clear recent files:", error);
|
||||
toast.error(t("fileManager.clearFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
isVisible: true,
|
||||
item,
|
||||
});
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu.isVisible) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
const menuElement = document.querySelector("[data-sidebar-context-menu]");
|
||||
|
||||
if (!menuElement?.contains(target)) {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
closeContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [contextMenu.isVisible]);
|
||||
|
||||
const loadDirectoryTree = async () => {
|
||||
const loadDirectoryTree = async (attempt = 0) => {
|
||||
if (!sshSessionId) return;
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(sshSessionId, "/");
|
||||
|
||||
const rootFiles = (response.files || []) as DirectoryItemData[];
|
||||
const rootFolders = rootFiles.filter(
|
||||
(item: DirectoryItemData) => item.type === "directory",
|
||||
@@ -287,7 +227,15 @@ export function FileManagerSidebar({
|
||||
children: rootTreeItems,
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
const status =
|
||||
(error as { status?: number })?.status ||
|
||||
(error as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 409 && attempt < 3) {
|
||||
// Another request was already listing "/" — retry after a short delay
|
||||
setTimeout(() => loadDirectoryTree(attempt + 1), 600);
|
||||
return;
|
||||
}
|
||||
console.error("Failed to load directory tree:", error);
|
||||
setDirectoryTree([
|
||||
{
|
||||
@@ -302,11 +250,116 @@ export function FileManagerSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick = (item: SidebarItem) => {
|
||||
if (item.type === "folder") {
|
||||
toggleFolder(item.id, item.path);
|
||||
onPathChange(item.path);
|
||||
} else if (item.type === "recent" || item.type === "pinned") {
|
||||
/**
|
||||
* Lazily fetches subdirectory contents and patches them into the tree state.
|
||||
* Called the first time a folder is expanded via FolderTree's onSelect.
|
||||
*/
|
||||
const loadSubdirectory = useCallback(
|
||||
async (folderId: string, folderPath: string) => {
|
||||
if (!sshSessionId) return;
|
||||
|
||||
try {
|
||||
const subResponse = await listSSHFiles(sshSessionId, folderPath);
|
||||
const subFiles = (subResponse.files || []) as DirectoryItemData[];
|
||||
const subFolders = subFiles.filter(
|
||||
(item: DirectoryItemData) => item.type === "directory",
|
||||
);
|
||||
|
||||
const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({
|
||||
id: `folder-${folder.path.replace(/\//g, "-")}`,
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
type: "folder" as const,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
}));
|
||||
|
||||
setDirectoryTree((prevTree) => {
|
||||
const updateChildren = (items: SidebarItem[]): SidebarItem[] =>
|
||||
items.map((item) => {
|
||||
if (item.id === folderId) {
|
||||
return { ...item, children: subTreeItems };
|
||||
}
|
||||
if (item.children) {
|
||||
return { ...item, children: updateChildren(item.children) };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return updateChildren(prevTree);
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const status =
|
||||
(error as { status?: number })?.status ||
|
||||
(error as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 409) {
|
||||
// Another request was listing this path — retry after the lock clears
|
||||
setTimeout(() => loadSubdirectory(folderId, folderPath), 600);
|
||||
return;
|
||||
}
|
||||
console.error("Failed to load subdirectory:", error);
|
||||
}
|
||||
},
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
// ─── Quick-access mutation handlers ──────────────────────────────────────────
|
||||
|
||||
const handleRemoveRecentFile = async (item: SidebarItem) => {
|
||||
if (!currentHost?.id) return;
|
||||
try {
|
||||
await removeRecentFile(currentHost.id, item.path);
|
||||
loadQuickAccessData();
|
||||
toast.success(
|
||||
t("fileManager.removedFromRecentFiles", { name: item.name }),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove recent file:", error);
|
||||
toast.error(t("fileManager.removeFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpinFile = async (item: SidebarItem) => {
|
||||
if (!currentHost?.id) return;
|
||||
try {
|
||||
await removePinnedFile(currentHost.id, item.path);
|
||||
loadQuickAccessData();
|
||||
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
|
||||
} catch (error) {
|
||||
console.error("Failed to unpin file:", error);
|
||||
toast.error(t("fileManager.unpinFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveShortcut = async (item: SidebarItem) => {
|
||||
if (!currentHost?.id) return;
|
||||
try {
|
||||
await removeFolderShortcut(currentHost.id, item.path);
|
||||
loadQuickAccessData();
|
||||
toast.success(t("fileManager.removedShortcut", { name: item.name }));
|
||||
} catch (error) {
|
||||
console.error("Failed to remove shortcut:", error);
|
||||
toast.error(t("fileManager.removeShortcutFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAllRecent = async () => {
|
||||
if (!currentHost?.id || recentItems.length === 0) return;
|
||||
try {
|
||||
await Promise.all(
|
||||
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
|
||||
);
|
||||
loadQuickAccessData();
|
||||
toast.success(t("fileManager.clearedAllRecentFiles"));
|
||||
} catch (error) {
|
||||
console.error("Failed to clear recent files:", error);
|
||||
toast.error(t("fileManager.clearFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Quick-access item click ──────────────────────────────────────────────────
|
||||
|
||||
const handleQuickAccessClick = (item: SidebarItem) => {
|
||||
if (item.type === "recent" || item.type === "pinned") {
|
||||
if (onFileOpen) {
|
||||
onFileOpen(item);
|
||||
} else {
|
||||
@@ -319,132 +372,180 @@ export function FileManagerSidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFolder = async (folderId: string, folderPath?: string) => {
|
||||
const newExpanded = new Set(expandedFolders);
|
||||
// ─── FolderTree directory selection (onSelect callback) ──────────────────────
|
||||
|
||||
if (newExpanded.has(folderId)) {
|
||||
newExpanded.delete(folderId);
|
||||
} else {
|
||||
newExpanded.add(folderId);
|
||||
|
||||
if (sshSessionId && folderPath && folderPath !== "/") {
|
||||
try {
|
||||
const subResponse = await listSSHFiles(sshSessionId, folderPath);
|
||||
|
||||
const subFiles = (subResponse.files || []) as DirectoryItemData[];
|
||||
const subFolders = subFiles.filter(
|
||||
(item: DirectoryItemData) => item.type === "directory",
|
||||
);
|
||||
|
||||
const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({
|
||||
id: `folder-${folder.path.replace(/\//g, "-")}`,
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
type: "folder" as const,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
}));
|
||||
|
||||
setDirectoryTree((prevTree) => {
|
||||
const updateChildren = (items: SidebarItem[]): SidebarItem[] => {
|
||||
return items.map((item) => {
|
||||
if (item.id === folderId) {
|
||||
return { ...item, children: subTreeItems };
|
||||
} else if (item.children) {
|
||||
return { ...item, children: updateChildren(item.children) };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
};
|
||||
return updateChildren(prevTree);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load subdirectory:", error);
|
||||
/**
|
||||
* Called by FolderTree whenever the user selects (clicks) a tree item.
|
||||
* We navigate to the folder and lazily load children on first visit.
|
||||
*/
|
||||
const handleDirectorySelect = useCallback(
|
||||
async (id: string) => {
|
||||
// Walk the tree to find the item by id
|
||||
const findItem = (items: SidebarItem[]): SidebarItem | null => {
|
||||
for (const item of items) {
|
||||
if (item.id === id) return item;
|
||||
if (item.children) {
|
||||
const found = findItem(item.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
setExpandedFolders(newExpanded);
|
||||
const item = findItem(directoryTree);
|
||||
if (!item) return;
|
||||
|
||||
// Navigate to path
|
||||
onPathChange(item.path);
|
||||
|
||||
// Lazy-load children the first time this folder is expanded
|
||||
if (
|
||||
sshSessionId &&
|
||||
item.path !== "/" &&
|
||||
!loadedFoldersRef.current.has(item.path)
|
||||
) {
|
||||
loadedFoldersRef.current.add(item.path);
|
||||
await loadSubdirectory(id, item.path);
|
||||
}
|
||||
},
|
||||
[directoryTree, onPathChange, sshSessionId, loadSubdirectory],
|
||||
);
|
||||
|
||||
// ─── Context menu ─────────────────────────────────────────────────────────────
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, isVisible: true, item });
|
||||
};
|
||||
|
||||
const renderSidebarItem = (item: SidebarItem, level: number = 0) => {
|
||||
const isExpanded = expandedFolders.has(item.id);
|
||||
const isActive = currentPath === item.path;
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu.isVisible) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
const menuElement = document.querySelector("[data-sidebar-context-menu]");
|
||||
if (!menuElement?.contains(target)) closeContextMenu();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") closeContextMenu();
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
}, 50);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [contextMenu.isVisible]);
|
||||
|
||||
// ─── Derive selected tree node + ancestors from currentPath ──────────────────
|
||||
|
||||
const { selectedTreeId, ancestorIds } = useMemo(() => {
|
||||
if (currentPath === "/")
|
||||
return { selectedTreeId: "root", ancestorIds: new Set<string>() };
|
||||
|
||||
const ancestors: string[] = [];
|
||||
const findByPath = (
|
||||
items: SidebarItem[],
|
||||
path: string[],
|
||||
): string | null => {
|
||||
for (const item of items) {
|
||||
if (item.path === currentPath) {
|
||||
ancestors.push(...path, "root");
|
||||
return item.id;
|
||||
}
|
||||
if (item.children) {
|
||||
const found = findByPath(item.children, [...path, item.id]);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const id = findByPath(directoryTree, []);
|
||||
return { selectedTreeId: id, ancestorIds: new Set(ancestors) };
|
||||
}, [currentPath, directoryTree]);
|
||||
|
||||
// ─── Render helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Recursively renders directory tree items using FolderTree.Item + Content.
|
||||
*
|
||||
* FolderTree.Item detects "has children" via React.Children.count > 0.
|
||||
* By always wrapping children in <FolderTree.Content> (even when the
|
||||
* children array is empty), every directory shows the expand chevron.
|
||||
* FolderTree.Content internally shows nothing when its own children are
|
||||
* absent, so an unloaded folder simply expands to an empty state while
|
||||
* the async fetch fills it in.
|
||||
*/
|
||||
const renderFolderTreeItem = (item: SidebarItem): React.ReactNode => (
|
||||
<FolderTree.Item key={item.id} id={item.id} label={item.name}>
|
||||
<FolderTree.Content>
|
||||
{item.children?.map((child) => renderFolderTreeItem(child))}
|
||||
</FolderTree.Content>
|
||||
</FolderTree.Item>
|
||||
);
|
||||
|
||||
/**
|
||||
* Styled quick-access row (recent / pinned / shortcut).
|
||||
* Mirrors FolderTree.Item's visual language but adds onContextMenu support.
|
||||
*/
|
||||
const renderQuickAccessItem = (item: SidebarItem, icon: React.ReactNode) => {
|
||||
const dirPath =
|
||||
item.type === "shortcut"
|
||||
? item.path
|
||||
: item.path.substring(0, item.path.lastIndexOf("/")) || "/";
|
||||
const isActive = currentPath === dirPath;
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1.5 text-sm cursor-pointer hover:bg-hover rounded",
|
||||
isActive && "bg-primary/20 text-primary",
|
||||
"text-foreground",
|
||||
)}
|
||||
style={{ paddingLeft: `${12 + level * 16}px`, paddingRight: "12px" }}
|
||||
onClick={() => handleItemClick(item)}
|
||||
onContextMenu={(e) => {
|
||||
if (
|
||||
item.type === "recent" ||
|
||||
item.type === "pinned" ||
|
||||
item.type === "shortcut"
|
||||
) {
|
||||
handleContextMenu(e, item);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.type === "folder" && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFolder(item.id, item.path);
|
||||
}}
|
||||
className="p-0.5 hover:bg-hover rounded"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{item.type === "folder" ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4" />
|
||||
)
|
||||
) : (
|
||||
<File className="w-4 h-4" />
|
||||
)}
|
||||
|
||||
<span className="truncate">{item.name}</span>
|
||||
</div>
|
||||
|
||||
{item.type === "folder" && isExpanded && item.children && (
|
||||
<div>
|
||||
{item.children.map((child) => renderSidebarItem(child, level + 1))}
|
||||
</div>
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1.5 pl-8 pr-3 text-sm cursor-pointer select-none transition-colors",
|
||||
isActive
|
||||
? "bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 border-r-2 border-blue-600"
|
||||
: "hover:bg-gray-100 dark:hover:bg-slate-700/50 text-foreground",
|
||||
)}
|
||||
onClick={() => handleQuickAccessClick(item)}
|
||||
onContextMenu={(e) => handleContextMenu(e, item)}
|
||||
title={item.path}
|
||||
>
|
||||
{/* indent spacer matching FolderTree level-1 padding */}
|
||||
<span className="w-3 shrink-0" aria-hidden="true" />
|
||||
{icon}
|
||||
<span className="flex-1 truncate">{item.name}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Section header + items list.
|
||||
* Returns null when items is empty so empty sections are hidden.
|
||||
*/
|
||||
const renderSection = (
|
||||
title: string,
|
||||
icon: React.ReactNode,
|
||||
headerIcon: React.ReactNode,
|
||||
items: SidebarItem[],
|
||||
renderItem: (item: SidebarItem) => React.ReactNode,
|
||||
) => {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{icon}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{headerIcon}
|
||||
{title}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{items.map((item) => renderSidebarItem(item))}
|
||||
</div>
|
||||
<div className="mt-0.5">{items.map((item) => renderItem(item))}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -452,53 +553,126 @@ export function FileManagerSidebar({
|
||||
const hasQuickAccessItems =
|
||||
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full flex flex-col bg-canvas border-r border-edge">
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-4">
|
||||
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-1">
|
||||
{/* ── Recent files ──────────────────────────────────────── */}
|
||||
{renderSection(
|
||||
t("fileManager.recent"),
|
||||
<Clock className="w-3 h-3" />,
|
||||
recentItems,
|
||||
(item) =>
|
||||
renderQuickAccessItem(
|
||||
item,
|
||||
<File
|
||||
size={15}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
currentPath ===
|
||||
(item.path.substring(0, item.path.lastIndexOf("/")) ||
|
||||
"/")
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-500 dark:text-gray-400",
|
||||
)}
|
||||
/>,
|
||||
),
|
||||
)}
|
||||
|
||||
{/* ── Pinned files ───────────────────────────────────────── */}
|
||||
{renderSection(
|
||||
t("fileManager.pinned"),
|
||||
<Star className="w-3 h-3" />,
|
||||
pinnedItems,
|
||||
(item) =>
|
||||
renderQuickAccessItem(
|
||||
item,
|
||||
<File
|
||||
size={15}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
currentPath ===
|
||||
(item.path.substring(0, item.path.lastIndexOf("/")) ||
|
||||
"/")
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-500 dark:text-gray-400",
|
||||
)}
|
||||
/>,
|
||||
),
|
||||
)}
|
||||
|
||||
{/* ── Folder shortcuts ───────────────────────────────────── */}
|
||||
{renderSection(
|
||||
t("fileManager.folderShortcuts"),
|
||||
<Bookmark className="w-3 h-3" />,
|
||||
shortcuts,
|
||||
(item) =>
|
||||
renderQuickAccessItem(
|
||||
item,
|
||||
<Folder
|
||||
size={15}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
currentPath === item.path
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-blue-500 dark:text-blue-400",
|
||||
)}
|
||||
/>,
|
||||
),
|
||||
)}
|
||||
|
||||
{/* ── Directory tree ─────────────────────────────────────── */}
|
||||
<div
|
||||
className={cn(hasQuickAccessItems && "pt-4 border-t border-edge")}
|
||||
className={cn(hasQuickAccessItems && "pt-3 border-t border-edge")}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
<Folder className="w-3 h-3" />
|
||||
{t("fileManager.directories")}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
{directoryTree.map((item) => renderSidebarItem(item))}
|
||||
|
||||
<div className="mt-1">
|
||||
{/*
|
||||
* FolderTree.Root manages its own expansion state internally.
|
||||
* We pass `onSelect` to be notified of clicks so we can:
|
||||
* 1. Navigate to the selected path (onPathChange)
|
||||
* 2. Lazy-load subdirectory children on first expand
|
||||
*
|
||||
* The root "/" folder is pre-expanded via defaultExpanded.
|
||||
*
|
||||
* className overrides strip the default card styling so the
|
||||
* tree blends seamlessly into the sidebar panel.
|
||||
*/}
|
||||
<FolderTree.Root
|
||||
id="sidebar-directory-tree"
|
||||
defaultExpanded={["root"]}
|
||||
selectedId={selectedTreeId}
|
||||
expandedIds={ancestorIds}
|
||||
onSelect={(id) => handleDirectorySelect(id)}
|
||||
className="bg-transparent border-0 rounded-none shadow-none"
|
||||
>
|
||||
{directoryTree.map((item) => renderFolderTreeItem(item))}
|
||||
</FolderTree.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Context menu ─────────────────────────────────────────────── */}
|
||||
{contextMenu.isVisible && contextMenu.item && (
|
||||
<>
|
||||
{/* Transparent backdrop to capture outside clicks */}
|
||||
<div className="fixed inset-0 z-40" />
|
||||
|
||||
<div
|
||||
data-sidebar-context-menu
|
||||
className="fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[160px] z-50 overflow-hidden"
|
||||
style={{
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
}}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
{/* Recent item actions */}
|
||||
{contextMenu.item.type === "recent" && (
|
||||
<>
|
||||
<button
|
||||
@@ -508,26 +682,23 @@ export function FileManagerSidebar({
|
||||
closeContextMenu();
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Clock className="w-4 h-4" />
|
||||
</div>
|
||||
<Clock className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1">
|
||||
{t("fileManager.removeFromRecentFiles")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{recentItems.length > 1 && (
|
||||
<>
|
||||
<div className="border-t border-edge" />
|
||||
<button
|
||||
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-hover text-red-400 hover:bg-red-500/10 first:rounded-t-lg last:rounded-b-lg"
|
||||
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-red-500/10 text-red-400 first:rounded-t-lg last:rounded-b-lg"
|
||||
onClick={() => {
|
||||
handleClearAllRecent();
|
||||
closeContextMenu();
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Clock className="w-4 h-4" />
|
||||
</div>
|
||||
<Clock className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1">
|
||||
{t("fileManager.clearAllRecentFiles")}
|
||||
</span>
|
||||
@@ -537,6 +708,7 @@ export function FileManagerSidebar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pinned item actions */}
|
||||
{contextMenu.item.type === "pinned" && (
|
||||
<button
|
||||
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-hover text-foreground first:rounded-t-lg last:rounded-b-lg"
|
||||
@@ -545,13 +717,12 @@ export function FileManagerSidebar({
|
||||
closeContextMenu();
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Star className="w-4 h-4" />
|
||||
</div>
|
||||
<Star className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1">{t("fileManager.unpinFile")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Shortcut item actions */}
|
||||
{contextMenu.item.type === "shortcut" && (
|
||||
<button
|
||||
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-hover text-foreground first:rounded-t-lg last:rounded-b-lg"
|
||||
@@ -560,9 +731,7 @@ export function FileManagerSidebar({
|
||||
closeContextMenu();
|
||||
}}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Bookmark className="w-4 h-4" />
|
||||
</div>
|
||||
<Bookmark className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1">
|
||||
{t("fileManager.removeShortcut")}
|
||||
</span>
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
|
||||
interface SudoPasswordDialogProps {
|
||||
open: boolean;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
import AudioPlayer from "react-h5-audio-player";
|
||||
import "react-h5-audio-player/lib/styles.css";
|
||||
import { Music } from "lucide-react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface AudioPreviewProps {
|
||||
file: FileItem;
|
||||
content: string;
|
||||
color: string;
|
||||
onMediaDimensionsChange?: (dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
|
||||
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function getAudioMimeType(filename: string): string {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
switch (ext) {
|
||||
case "mp3":
|
||||
return "audio/mpeg";
|
||||
case "wav":
|
||||
return "audio/wav";
|
||||
case "flac":
|
||||
return "audio/flac";
|
||||
case "ogg":
|
||||
return "audio/ogg";
|
||||
case "aac":
|
||||
return "audio/aac";
|
||||
case "m4a":
|
||||
return "audio/mp4";
|
||||
default:
|
||||
return "audio/mpeg";
|
||||
}
|
||||
}
|
||||
|
||||
export function AudioPreview({
|
||||
file,
|
||||
content,
|
||||
color,
|
||||
onMediaDimensionsChange,
|
||||
}: AudioPreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||
const audioUrl = `data:${getAudioMimeType(file.name)};base64,${content}`;
|
||||
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-full">
|
||||
<div className="w-full max-w-2xl">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"w-32 h-32 rounded-lg bg-gradient-to-br from-pink-100 to-purple-100 flex items-center justify-center shadow-lg",
|
||||
color,
|
||||
)}
|
||||
>
|
||||
<Music className="w-16 h-16 text-pink-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-foreground text-lg mb-1">
|
||||
{file.name.replace(/\.[^/.]+$/, "")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{ext.toUpperCase()} • {formatFileSize(file.size, t)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<AudioPlayer
|
||||
src={audioUrl}
|
||||
onLoadedMetadata={() => {
|
||||
onMediaDimensionsChange?.({
|
||||
width: 600,
|
||||
height: 400,
|
||||
});
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error("Audio playback error:", e);
|
||||
}}
|
||||
showJumpControls={false}
|
||||
showSkipControls={false}
|
||||
showDownloadProgress={true}
|
||||
customAdditionalControls={[]}
|
||||
customVolumeControls={[]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
toggleComment,
|
||||
} from "@codemirror/commands";
|
||||
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
|
||||
|
||||
export interface CodeEditorHandle {
|
||||
openSearchPanel: () => void;
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
fileName: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onChange: (value: string) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
}
|
||||
|
||||
function getLanguageExtension(filename: string) {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||
const baseName = filename.toLowerCase();
|
||||
|
||||
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
|
||||
return loadLanguage(baseName);
|
||||
}
|
||||
|
||||
const langMap: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
py: "python",
|
||||
java: "java",
|
||||
cpp: "cpp",
|
||||
c: "c",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
rb: "ruby",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
html: "html",
|
||||
css: "css",
|
||||
scss: "sass",
|
||||
less: "less",
|
||||
json: "json",
|
||||
xml: "xml",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
sql: "sql",
|
||||
sh: "shell",
|
||||
bash: "shell",
|
||||
zsh: "shell",
|
||||
vue: "vue",
|
||||
svelte: "svelte",
|
||||
md: "markdown",
|
||||
conf: "shell",
|
||||
ini: "properties",
|
||||
};
|
||||
|
||||
const language = langMap[ext];
|
||||
return language ? loadLanguage(language) : null;
|
||||
}
|
||||
|
||||
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
|
||||
function CodeEditor(
|
||||
{ fileName, value, placeholder, onChange, onFocus, onBlur },
|
||||
ref,
|
||||
) {
|
||||
const editorRef = useRef<{ view?: EditorView } | null>(null);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const languageExtension = getLanguageExtension(fileName);
|
||||
|
||||
return [
|
||||
...(languageExtension ? [languageExtension] : []),
|
||||
history(),
|
||||
search(),
|
||||
autocompletion(),
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
{
|
||||
key: "Mod-/",
|
||||
run: toggleComment,
|
||||
preventDefault: true,
|
||||
},
|
||||
{
|
||||
key: "Mod-h",
|
||||
run: () => false,
|
||||
preventDefault: true,
|
||||
},
|
||||
]),
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
|
||||
},
|
||||
".cm-editor": {
|
||||
height: "100%",
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [fileName]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
openSearchPanel: () => {
|
||||
const view = editorRef.current?.view;
|
||||
if (view) {
|
||||
openSearchPanel(view);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
extensions={extensions}
|
||||
theme={oneDark}
|
||||
placeholder={placeholder}
|
||||
className="h-full"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: false,
|
||||
scrollPastEnd: false,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { Suspense, lazy, useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -38,29 +38,33 @@ import {
|
||||
SiDocker,
|
||||
} from "react-icons/si";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
toggleComment,
|
||||
} from "@codemirror/commands";
|
||||
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
|
||||
import { PhotoProvider, PhotoView } from "react-photo-view";
|
||||
import "react-photo-view/dist/react-photo-view.css";
|
||||
import AudioPlayer from "react-h5-audio-player";
|
||||
import "react-h5-audio-player/lib/styles.css";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { Document, Page, pdfjs } from "react-pdf";
|
||||
import type { CodeEditorHandle } from "./CodeEditor.tsx";
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
|
||||
const CodeEditor = lazy(() =>
|
||||
import("./CodeEditor.tsx").then((module) => ({
|
||||
default: module.CodeEditor,
|
||||
})),
|
||||
);
|
||||
const ImagePreview = lazy(() =>
|
||||
import("./ImagePreview.tsx").then((module) => ({
|
||||
default: module.ImagePreview,
|
||||
})),
|
||||
);
|
||||
const MarkdownRenderer = lazy(() =>
|
||||
import("./MarkdownRenderer.tsx").then((module) => ({
|
||||
default: module.MarkdownRenderer,
|
||||
})),
|
||||
);
|
||||
const PdfPreview = lazy(() =>
|
||||
import("./PdfPreview.tsx").then((module) => ({
|
||||
default: module.PdfPreview,
|
||||
})),
|
||||
);
|
||||
const AudioPreview = lazy(() =>
|
||||
import("./AudioPreview.tsx").then((module) => ({
|
||||
default: module.AudioPreview,
|
||||
})),
|
||||
);
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
@@ -235,52 +239,6 @@ function getFileType(filename: string): {
|
||||
}
|
||||
}
|
||||
|
||||
function getLanguageExtension(filename: string) {
|
||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
||||
const baseName = filename.toLowerCase();
|
||||
|
||||
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
|
||||
return loadLanguage(baseName);
|
||||
}
|
||||
|
||||
const langMap: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "jsx",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
py: "python",
|
||||
java: "java",
|
||||
cpp: "cpp",
|
||||
c: "c",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
rb: "ruby",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
html: "html",
|
||||
css: "css",
|
||||
scss: "sass",
|
||||
less: "less",
|
||||
json: "json",
|
||||
xml: "xml",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
sql: "sql",
|
||||
sh: "shell",
|
||||
bash: "shell",
|
||||
zsh: "shell",
|
||||
vue: "vue",
|
||||
svelte: "svelte",
|
||||
md: "markdown",
|
||||
conf: "shell",
|
||||
ini: "properties",
|
||||
};
|
||||
|
||||
const language = langMap[ext];
|
||||
return language ? loadLanguage(language) : null;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
|
||||
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
@@ -288,6 +246,17 @@ function formatFileSize(bytes?: number, t?: (key: string) => string): string {
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function PreviewFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileViewer({
|
||||
file,
|
||||
content = "",
|
||||
@@ -308,39 +277,11 @@ export function FileViewer({
|
||||
const [forceShowAsText, setForceShowAsText] = useState(false);
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
|
||||
const [editorFocused, setEditorFocused] = useState(false);
|
||||
const [imageLoadError, setImageLoadError] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
const [numPages, setNumPages] = useState<number | null>(null);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [pdfScale, setPdfScale] = useState(1.2);
|
||||
const [pdfError, setPdfError] = useState(false);
|
||||
const [markdownEditMode, setMarkdownEditMode] = useState(false);
|
||||
const editorRef = useRef<{
|
||||
view?: { dispatch: (transaction: unknown) => void };
|
||||
} | null>(null);
|
||||
const editorRef = useRef<CodeEditorHandle | null>(null);
|
||||
|
||||
const fileTypeInfo = getFileType(file.name);
|
||||
|
||||
const getImageDataUrl = (content: string, fileName: string): string => {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
svg: "image/svg+xml",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
tiff: "image/tiff",
|
||||
tif: "image/tiff",
|
||||
};
|
||||
|
||||
const mimeType = mimeTypes[ext] || "image/png";
|
||||
return `data:${mimeType};base64,${content}`;
|
||||
};
|
||||
|
||||
const WARNING_SIZE = 50 * 1024 * 1024;
|
||||
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
@@ -467,14 +408,7 @@ export function FileViewer({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (editorRef.current) {
|
||||
const view = editorRef.current.view;
|
||||
if (view) {
|
||||
openSearchPanel(view);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={() => editorRef.current?.openSearchPanel()}
|
||||
className="flex items-center gap-2"
|
||||
title={t("fileManager.searchInFile")}
|
||||
>
|
||||
@@ -552,27 +486,35 @@ export function FileViewer({
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.search")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+F
|
||||
</kbd>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+F
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.replace")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+H
|
||||
</kbd>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+H
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.findNext")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
F3
|
||||
</kbd>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
F3
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.findPrevious")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Shift+F3
|
||||
</kbd>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Shift+F3
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -583,51 +525,67 @@ export function FileViewer({
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.save")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+S
|
||||
</kbd>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+S
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.selectAll")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+A
|
||||
</kbd>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+A
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.undo")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+Z
|
||||
</kbd>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+Z
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.redo")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+Y
|
||||
</kbd>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+Y
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.toggleComment")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+/
|
||||
</kbd>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+/
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.autoComplete")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+Space
|
||||
</kbd>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Ctrl+Space
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.moveLineUp")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Alt+↑
|
||||
</kbd>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Alt+↑
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("fileManager.moveLineDown")}</span>
|
||||
<kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
Alt+↓
|
||||
</kbd>
|
||||
<Kbd className="px-2 py-1 bg-background rounded text-xs">
|
||||
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
|
||||
Alt+↓
|
||||
</KbdKey>
|
||||
</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -700,136 +658,32 @@ export function FileViewer({
|
||||
)}
|
||||
|
||||
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
|
||||
<div className="p-6 flex items-center justify-center h-full relative">
|
||||
{imageLoadError ? (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{t("fileManager.imageLoadError")}
|
||||
</h3>
|
||||
<p className="text-sm mb-4">{file.name}</p>
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownload}
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t("fileManager.download")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<PhotoProvider maskOpacity={0.7}>
|
||||
<PhotoView src={getImageDataUrl(content, file.name)}>
|
||||
<img
|
||||
src={getImageDataUrl(content, file.name)}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
|
||||
style={{ maxHeight: "calc(100vh - 200px)" }}
|
||||
onLoad={(e) => {
|
||||
setImageLoading(false);
|
||||
setImageLoadError(false);
|
||||
|
||||
const img = e.currentTarget;
|
||||
if (
|
||||
onMediaDimensionsChange &&
|
||||
img.naturalWidth &&
|
||||
img.naturalHeight
|
||||
) {
|
||||
onMediaDimensionsChange({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
setImageLoading(false);
|
||||
setImageLoadError(true);
|
||||
}}
|
||||
/>
|
||||
</PhotoView>
|
||||
</PhotoProvider>
|
||||
)}
|
||||
|
||||
{imageLoading && !imageLoadError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading image...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Suspense fallback={<PreviewFallback label="Loading image..." />}>
|
||||
<ImagePreview
|
||||
content={content}
|
||||
fileName={file.name}
|
||||
onDownload={onDownload}
|
||||
onMediaDimensionsChange={onMediaDimensionsChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{shouldShowAsText && !showLargeFileWarning && (
|
||||
<div className="h-full flex flex-col">
|
||||
{isEditable ? (
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={editedContent}
|
||||
onChange={(value) => handleContentChange(value)}
|
||||
onFocus={() => setEditorFocused(true)}
|
||||
onBlur={() => setEditorFocused(false)}
|
||||
extensions={[
|
||||
...(getLanguageExtension(file.name)
|
||||
? [getLanguageExtension(file.name)!]
|
||||
: []),
|
||||
history(),
|
||||
search(),
|
||||
autocompletion(),
|
||||
keymap.of([
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
{
|
||||
key: "Mod-/",
|
||||
run: toggleComment,
|
||||
preventDefault: true,
|
||||
},
|
||||
{
|
||||
key: "Mod-h",
|
||||
run: () => {
|
||||
return false;
|
||||
},
|
||||
preventDefault: true,
|
||||
},
|
||||
]),
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor:
|
||||
"var(--scrollbar-thumb) var(--scrollbar-track)",
|
||||
},
|
||||
".cm-editor": {
|
||||
height: "100%",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
theme={oneDark}
|
||||
placeholder={t("fileManager.startTyping")}
|
||||
className="h-full"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: false,
|
||||
scrollPastEnd: false,
|
||||
}}
|
||||
/>
|
||||
<Suspense
|
||||
fallback={<PreviewFallback label="Loading editor..." />}
|
||||
>
|
||||
<CodeEditor
|
||||
ref={editorRef}
|
||||
fileName={file.name}
|
||||
value={editedContent}
|
||||
onChange={handleContentChange}
|
||||
onFocus={() => setEditorFocused(true)}
|
||||
onBlur={() => setEditorFocused(false)}
|
||||
placeholder={t("fileManager.startTyping")}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto thin-scrollbar bg-background text-foreground">
|
||||
{editedContent || content || t("fileManager.fileIsEmpty")}
|
||||
@@ -967,226 +821,27 @@ export function FileViewer({
|
||||
|
||||
<div className="flex-1 overflow-auto thin-scrollbar bg-muted/10">
|
||||
<div className="p-4">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(
|
||||
className || "",
|
||||
);
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="rounded-lg"
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-bold mb-4 mt-6 text-foreground border-b border-border pb-2">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-semibold mb-3 mt-5 text-foreground border-b border-border pb-1">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-lg font-semibold mb-2 mt-4 text-foreground">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-base font-semibold mb-2 mt-3 text-foreground">
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-3 text-foreground leading-relaxed">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-3 ml-4 list-disc text-foreground">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-3 ml-4 list-decimal text-foreground">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="mb-1 text-foreground">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-blue-500 pl-3 mb-3 italic text-muted-foreground bg-muted/30 py-1">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="mb-3 overflow-x-auto thin-scrollbar">
|
||||
<table className="min-w-full border border-border rounded-lg text-sm">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-muted">{children}</thead>
|
||||
),
|
||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }) => (
|
||||
<tr className="border-b border-border">
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-3 py-2 text-left font-semibold text-foreground">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-3 py-2 text-foreground">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
<Suspense
|
||||
fallback={
|
||||
<PreviewFallback label="Loading preview..." />
|
||||
}
|
||||
>
|
||||
{editedContent || "Nothing to preview yet..."}
|
||||
</ReactMarkdown>
|
||||
<MarkdownRenderer
|
||||
compact
|
||||
content={editedContent || "Nothing to preview yet..."}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto thin-scrollbar p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="rounded-lg"
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-3xl font-bold mb-6 mt-8 text-foreground border-b border-border pb-2">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-2xl font-semibold mb-4 mt-6 text-foreground border-b border-border pb-1">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-xl font-semibold mb-3 mt-4 text-foreground">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-lg font-semibold mb-2 mt-3 text-foreground">
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 text-foreground leading-relaxed">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 ml-6 list-disc text-foreground">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 ml-6 list-decimal text-foreground">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="mb-1 text-foreground">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-blue-500 pl-4 mb-4 italic text-muted-foreground bg-muted/30 py-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="mb-4 overflow-x-auto thin-scrollbar">
|
||||
<table className="min-w-full border border-border rounded-lg">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-muted">{children}</thead>
|
||||
),
|
||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }) => (
|
||||
<tr className="border-b border-border">{children}</tr>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-4 py-2 text-left font-semibold text-foreground">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-2 text-foreground">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
<Suspense
|
||||
fallback={<PreviewFallback label="Loading preview..." />}
|
||||
>
|
||||
{editedContent}
|
||||
</ReactMarkdown>
|
||||
<MarkdownRenderer content={editedContent} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1195,202 +850,28 @@ export function FileViewer({
|
||||
)}
|
||||
|
||||
{fileTypeInfo.type === "pdf" && !showLargeFileWarning && (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageNumber(Math.max(1, pageNumber - 1))}
|
||||
disabled={pageNumber <= 1}
|
||||
>
|
||||
{t("fileManager.previous")}
|
||||
</Button>
|
||||
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border">
|
||||
{t("fileManager.pageXOfY", {
|
||||
current: pageNumber,
|
||||
total: numPages || 0,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPageNumber(Math.min(numPages || 1, pageNumber + 1))
|
||||
}
|
||||
disabled={!numPages || pageNumber >= numPages}
|
||||
>
|
||||
{t("fileManager.next")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPdfScale(Math.max(0.5, pdfScale - 0.2))}
|
||||
>
|
||||
{t("fileManager.zoomOut")}
|
||||
</Button>
|
||||
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border min-w-[80px] text-center">
|
||||
{Math.round(pdfScale * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPdfScale(Math.min(3.0, pdfScale + 0.2))}
|
||||
>
|
||||
{t("fileManager.zoomIn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto thin-scrollbar p-6 bg-surface">
|
||||
<div className="flex justify-center">
|
||||
{pdfError ? (
|
||||
<div className="text-center text-muted-foreground p-8">
|
||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
Cannot load PDF
|
||||
</h3>
|
||||
<p className="text-sm mb-4">
|
||||
There was an error loading this PDF file.
|
||||
</p>
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownload}
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t("fileManager.download")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Document
|
||||
file={`data:application/pdf;base64,${content}`}
|
||||
onLoadSuccess={({ numPages }) => {
|
||||
setNumPages(numPages);
|
||||
setPdfError(false);
|
||||
|
||||
if (onMediaDimensionsChange) {
|
||||
onMediaDimensionsChange({
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onLoadError={(error) => {
|
||||
console.error("PDF load error:", error);
|
||||
setPdfError(true);
|
||||
}}
|
||||
loading={
|
||||
<div className="text-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading PDF...
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Page
|
||||
pageNumber={pageNumber}
|
||||
scale={pdfScale}
|
||||
className="shadow-lg"
|
||||
loading={
|
||||
<div className="text-center p-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Loading page...
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Document>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={<PreviewFallback label="Loading PDF viewer..." />}
|
||||
>
|
||||
<PdfPreview
|
||||
content={content}
|
||||
onDownload={onDownload}
|
||||
onMediaDimensionsChange={onMediaDimensionsChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
|
||||
<div className="p-6 flex items-center justify-center h-full">
|
||||
<div className="w-full max-w-2xl">
|
||||
{(() => {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() || "";
|
||||
const mimeType = (() => {
|
||||
switch (ext) {
|
||||
case "mp3":
|
||||
return "audio/mpeg";
|
||||
case "wav":
|
||||
return "audio/wav";
|
||||
case "flac":
|
||||
return "audio/flac";
|
||||
case "ogg":
|
||||
return "audio/ogg";
|
||||
case "aac":
|
||||
return "audio/aac";
|
||||
case "m4a":
|
||||
return "audio/mp4";
|
||||
default:
|
||||
return "audio/mpeg";
|
||||
}
|
||||
})();
|
||||
|
||||
const audioUrl = `data:${mimeType};base64,${content}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"w-32 h-32 rounded-lg bg-gradient-to-br from-pink-100 to-purple-100 flex items-center justify-center shadow-lg",
|
||||
fileTypeInfo.color,
|
||||
)}
|
||||
>
|
||||
<Music className="w-16 h-16 text-pink-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-foreground text-lg mb-1">
|
||||
{file.name.replace(/\.[^/.]+$/, "")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{ext.toUpperCase()} • {formatFileSize(file.size, t)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<AudioPlayer
|
||||
src={audioUrl}
|
||||
onLoadedMetadata={() => {
|
||||
if (onMediaDimensionsChange) {
|
||||
onMediaDimensionsChange({
|
||||
width: 600,
|
||||
height: 400,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error("Audio playback error:", e);
|
||||
}}
|
||||
showJumpControls={false}
|
||||
showSkipControls={false}
|
||||
showDownloadProgress={true}
|
||||
customAdditionalControls={[]}
|
||||
customVolumeControls={[]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={<PreviewFallback label="Loading audio player..." />}
|
||||
>
|
||||
<AudioPreview
|
||||
file={file}
|
||||
content={content}
|
||||
color={fileTypeInfo.color}
|
||||
onMediaDimensionsChange={onMediaDimensionsChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{fileTypeInfo.type === "unknown" &&
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useState } from "react";
|
||||
import { PhotoProvider, PhotoView } from "react-photo-view";
|
||||
import "react-photo-view/dist/react-photo-view.css";
|
||||
import { AlertCircle, Download } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ImagePreviewProps {
|
||||
content: string;
|
||||
fileName: string;
|
||||
onDownload?: () => void;
|
||||
onMediaDimensionsChange?: (dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function getImageDataUrl(content: string, fileName: string): string {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
|
||||
const mimeTypes: Record<string, string> = {
|
||||
svg: "image/svg+xml",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
tiff: "image/tiff",
|
||||
tif: "image/tiff",
|
||||
};
|
||||
|
||||
const mimeType = mimeTypes[ext] || "image/png";
|
||||
return `data:${mimeType};base64,${content}`;
|
||||
}
|
||||
|
||||
export function ImagePreview({
|
||||
content,
|
||||
fileName,
|
||||
onDownload,
|
||||
onMediaDimensionsChange,
|
||||
}: ImagePreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [imageLoadError, setImageLoadError] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
const imageUrl = getImageDataUrl(content, fileName);
|
||||
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center h-full relative">
|
||||
{imageLoadError ? (
|
||||
<div className="text-center text-muted-foreground">
|
||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{t("fileManager.imageLoadError")}
|
||||
</h3>
|
||||
<p className="text-sm mb-4">{fileName}</p>
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownload}
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t("fileManager.download")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<PhotoProvider maskOpacity={0.7}>
|
||||
<PhotoView src={imageUrl}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={fileName}
|
||||
className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
|
||||
style={{ maxHeight: "calc(100vh - 200px)" }}
|
||||
onLoad={(e) => {
|
||||
setImageLoading(false);
|
||||
setImageLoadError(false);
|
||||
|
||||
const img = e.currentTarget;
|
||||
if (
|
||||
onMediaDimensionsChange &&
|
||||
img.naturalWidth &&
|
||||
img.naturalHeight
|
||||
) {
|
||||
onMediaDimensionsChange({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
setImageLoading(false);
|
||||
setImageLoadError(true);
|
||||
}}
|
||||
/>
|
||||
</PhotoView>
|
||||
</PhotoProvider>
|
||||
)}
|
||||
|
||||
{imageLoading && !imageLoadError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading image...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({
|
||||
content,
|
||||
compact = false,
|
||||
}: MarkdownRendererProps) {
|
||||
const h1Class = compact
|
||||
? "text-2xl font-bold mb-4 mt-6 text-foreground border-b border-border pb-2"
|
||||
: "text-3xl font-bold mb-6 mt-8 text-foreground border-b border-border pb-2";
|
||||
const h2Class = compact
|
||||
? "text-xl font-semibold mb-3 mt-5 text-foreground border-b border-border pb-1"
|
||||
: "text-2xl font-semibold mb-4 mt-6 text-foreground border-b border-border pb-1";
|
||||
const h3Class = compact
|
||||
? "text-lg font-semibold mb-2 mt-4 text-foreground"
|
||||
: "text-xl font-semibold mb-3 mt-4 text-foreground";
|
||||
const h4Class = compact
|
||||
? "text-base font-semibold mb-2 mt-3 text-foreground"
|
||||
: "text-lg font-semibold mb-2 mt-3 text-foreground";
|
||||
const pClass = compact
|
||||
? "mb-3 text-foreground leading-relaxed"
|
||||
: "mb-4 text-foreground leading-relaxed";
|
||||
const listClass = compact
|
||||
? "mb-3 ml-4 text-foreground"
|
||||
: "mb-4 ml-6 text-foreground";
|
||||
const quoteClass = compact
|
||||
? "border-l-4 border-blue-500 pl-3 mb-3 italic text-muted-foreground bg-muted/30 py-1"
|
||||
: "border-l-4 border-blue-500 pl-4 mb-4 italic text-muted-foreground bg-muted/30 py-2";
|
||||
const tableWrapClass = compact
|
||||
? "mb-3 overflow-x-auto thin-scrollbar"
|
||||
: "mb-4 overflow-x-auto thin-scrollbar";
|
||||
const tableClass = compact
|
||||
? "min-w-full border border-border rounded-lg text-sm"
|
||||
: "min-w-full border border-border rounded-lg";
|
||||
const thClass = compact
|
||||
? "px-3 py-2 text-left font-semibold text-foreground"
|
||||
: "px-4 py-2 text-left font-semibold text-foreground";
|
||||
const tdClass = compact
|
||||
? "px-3 py-2 text-foreground"
|
||||
: "px-4 py-2 text-foreground";
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={syntaxTheme}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="rounded-lg"
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
h1: ({ children }) => <h1 className={h1Class}>{children}</h1>,
|
||||
h2: ({ children }) => <h2 className={h2Class}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 className={h3Class}>{children}</h3>,
|
||||
h4: ({ children }) => <h4 className={h4Class}>{children}</h4>,
|
||||
p: ({ children }) => <p className={pClass}>{children}</p>,
|
||||
ul: ({ children }) => (
|
||||
<ul className={`${listClass} list-disc`}>{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className={`${listClass} list-decimal`}>{children}</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="mb-1 text-foreground">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className={quoteClass}>{children}</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className={tableWrapClass}>
|
||||
<table className={tableClass}>{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
|
||||
tbody: ({ children }) => <tbody>{children}</tbody>,
|
||||
tr: ({ children }) => (
|
||||
<tr className="border-b border-border">{children}</tr>
|
||||
),
|
||||
th: ({ children }) => <th className={thClass}>{children}</th>,
|
||||
td: ({ children }) => <td className={tdClass}>{children}</td>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import React, { useState } from "react";
|
||||
import { Document, Page, pdfjs } from "react-pdf";
|
||||
import { AlertCircle, Download } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
|
||||
|
||||
interface PdfPreviewProps {
|
||||
content: string;
|
||||
onDownload?: () => void;
|
||||
onMediaDimensionsChange?: (dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function PdfPreview({
|
||||
content,
|
||||
onDownload,
|
||||
onMediaDimensionsChange,
|
||||
}: PdfPreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [numPages, setNumPages] = useState<number | null>(null);
|
||||
const [pageNumber, setPageNumber] = useState(1);
|
||||
const [pdfScale, setPdfScale] = useState(1.2);
|
||||
const [pdfError, setPdfError] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageNumber(Math.max(1, pageNumber - 1))}
|
||||
disabled={pageNumber <= 1}
|
||||
>
|
||||
{t("fileManager.previous")}
|
||||
</Button>
|
||||
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border">
|
||||
{t("fileManager.pageXOfY", {
|
||||
current: pageNumber,
|
||||
total: numPages || 0,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPageNumber(Math.min(numPages || 1, pageNumber + 1))
|
||||
}
|
||||
disabled={!numPages || pageNumber >= numPages}
|
||||
>
|
||||
{t("fileManager.next")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPdfScale(Math.max(0.5, pdfScale - 0.2))}
|
||||
>
|
||||
{t("fileManager.zoomOut")}
|
||||
</Button>
|
||||
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border min-w-[80px] text-center">
|
||||
{Math.round(pdfScale * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPdfScale(Math.min(3.0, pdfScale + 0.2))}
|
||||
>
|
||||
{t("fileManager.zoomIn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto thin-scrollbar p-6 bg-surface">
|
||||
<div className="flex justify-center">
|
||||
{pdfError ? (
|
||||
<div className="text-center text-muted-foreground p-8">
|
||||
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
|
||||
<h3 className="text-lg font-medium mb-2">Cannot load PDF</h3>
|
||||
<p className="text-sm mb-4">
|
||||
There was an error loading this PDF file.
|
||||
</p>
|
||||
{onDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownload}
|
||||
className="flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{t("fileManager.download")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Document
|
||||
file={`data:application/pdf;base64,${content}`}
|
||||
onLoadSuccess={({ numPages }) => {
|
||||
setNumPages(numPages);
|
||||
setPdfError(false);
|
||||
|
||||
onMediaDimensionsChange?.({
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
}}
|
||||
onLoadError={(error) => {
|
||||
console.error("PDF load error:", error);
|
||||
setPdfError(true);
|
||||
}}
|
||||
loading={
|
||||
<div className="text-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading PDF...
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Page
|
||||
pageNumber={pageNumber}
|
||||
scale={pdfScale}
|
||||
className="shadow-lg"
|
||||
loading={
|
||||
<div className="text-center p-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Loading page...
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Document>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Shield } from "lucide-react";
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GuacamoleDisplay } from "@/ui/desktop/apps/features/guacamole/Guacamole
|
||||
import { FullScreenAppWrapper } from "@/ui/desktop/apps/FullScreenAppWrapper.tsx";
|
||||
import { getGuacamoleTokenFromHost } from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
interface GuacamoleAppProps {
|
||||
hostId?: string;
|
||||
@@ -46,7 +47,7 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
|
||||
|
||||
interface GuacamoleAppInnerProps {
|
||||
hostId: number;
|
||||
hostConfig: any;
|
||||
hostConfig: Pick<SSHHost, "connectionType">;
|
||||
}
|
||||
|
||||
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
} from "react";
|
||||
import Guacamole from "guacamole-common-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getCookie, isElectron, isEmbeddedMode } from "@/ui/main-axios.ts";
|
||||
import {
|
||||
getGuacamoleToken,
|
||||
isElectron,
|
||||
isEmbeddedMode,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||
@@ -64,7 +68,6 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const windowFocusedRef = useRef(
|
||||
typeof document === "undefined" ? true : document.hasFocus(),
|
||||
);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -108,45 +111,35 @@ export const GuacamoleDisplay = forwardRef<
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
let token: string;
|
||||
const protocol = connectionConfig.protocol ?? connectionConfig.type;
|
||||
|
||||
if (connectionConfig.token) {
|
||||
token = connectionConfig.token;
|
||||
} else {
|
||||
const jwtToken = getCookie("jwt");
|
||||
if (!jwtToken) {
|
||||
onError?.("Authentication required");
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = isDev
|
||||
? "http://localhost:30001"
|
||||
: isElectron()
|
||||
? (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl || "http://127.0.0.1:30001"
|
||||
: `${window.location.origin}`;
|
||||
|
||||
const response = await fetch(`${baseUrl}/guacamole/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
},
|
||||
body: JSON.stringify(connectionConfig),
|
||||
credentials: "include",
|
||||
const data = await getGuacamoleToken({
|
||||
protocol: protocol ?? "rdp",
|
||||
hostname: String(connectionConfig.hostname ?? ""),
|
||||
port: connectionConfig.port,
|
||||
username: connectionConfig.username,
|
||||
password: connectionConfig.password,
|
||||
domain: connectionConfig.domain,
|
||||
security:
|
||||
typeof connectionConfig.security === "string"
|
||||
? connectionConfig.security
|
||||
: undefined,
|
||||
ignoreCert:
|
||||
typeof connectionConfig.ignoreCert === "boolean"
|
||||
? connectionConfig.ignoreCert
|
||||
: undefined,
|
||||
guacamoleConfig: connectionConfig.guacamoleConfig as Parameters<
|
||||
typeof getGuacamoleToken
|
||||
>[0]["guacamoleConfig"],
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error || "Failed to get connection token");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
token = data.token;
|
||||
}
|
||||
|
||||
const width = connectionConfig.width ?? containerWidth ?? 1280;
|
||||
const height = connectionConfig.height ?? containerHeight ?? 720;
|
||||
const protocol = connectionConfig.protocol ?? connectionConfig.type;
|
||||
const dpi = protocol === "rdp" ? (connectionConfig.dpi ?? 96) : null;
|
||||
|
||||
const wsBase = isDev
|
||||
@@ -281,7 +274,6 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const connect = useCallback(async () => {
|
||||
if (isConnectingRef.current) return;
|
||||
isConnectingRef.current = true;
|
||||
setIsConnecting(true);
|
||||
setIsReady(false);
|
||||
|
||||
let containerWidth = containerRef.current?.clientWidth || 0;
|
||||
@@ -295,7 +287,6 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
|
||||
if (!wsUrl) {
|
||||
isConnectingRef.current = false;
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -364,19 +355,16 @@ export const GuacamoleDisplay = forwardRef<
|
||||
case 0:
|
||||
break;
|
||||
case 1:
|
||||
setIsConnecting(true);
|
||||
break;
|
||||
case 2:
|
||||
break;
|
||||
case 3:
|
||||
setIsConnecting(false);
|
||||
setIsReady(true);
|
||||
onConnect?.();
|
||||
break;
|
||||
case 4:
|
||||
break;
|
||||
case 5:
|
||||
setIsConnecting(false);
|
||||
setIsReady(false);
|
||||
hasKeyboardFocusRef.current = false;
|
||||
refreshKeyboardHandlers();
|
||||
@@ -387,7 +375,6 @@ export const GuacamoleDisplay = forwardRef<
|
||||
|
||||
client.onerror = (error: Guacamole.Status) => {
|
||||
const errorMessage = error.message || "Connection error";
|
||||
setIsConnecting(false);
|
||||
setIsReady(false);
|
||||
onError?.(errorMessage);
|
||||
};
|
||||
|
||||
@@ -37,18 +37,25 @@ import {
|
||||
FirewallWidget,
|
||||
} from "./widgets";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
import { RefreshCcw, RefreshCw, RefreshCwOff } from "lucide-react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ui/desktop/navigation/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ui/desktop/navigation/connection-log/ConnectionLog.tsx";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
|
||||
interface QuickAction {
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
|
||||
|
||||
type ConnectionLogError = Error & {
|
||||
connectionLogs?: ConnectionLogPayload[];
|
||||
};
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -62,14 +69,6 @@ interface HostConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
type: string;
|
||||
title?: string;
|
||||
hostConfig?: HostConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ServerProps {
|
||||
hostConfig?: HostConfig;
|
||||
title?: string;
|
||||
@@ -92,9 +91,7 @@ function ServerStatsInner({
|
||||
clearLogs,
|
||||
isExpanded: isConnectionLogExpanded,
|
||||
} = useConnectionLog();
|
||||
const { addTab, tabs, currentTab, removeTab } = useTabs() as {
|
||||
addTab: (tab: { type: string; [key: string]: unknown }) => number;
|
||||
tabs: TabData[];
|
||||
const { currentTab, removeTab } = useTabs() as {
|
||||
currentTab: number | null;
|
||||
removeTab: (tabId: number) => void;
|
||||
};
|
||||
@@ -419,7 +416,7 @@ function ServerStatsInner({
|
||||
if (cancelled) return;
|
||||
|
||||
if (result?.connectionLogs) {
|
||||
result.connectionLogs.forEach((log: any) => {
|
||||
result.connectionLogs.forEach((log) => {
|
||||
addLog({
|
||||
type: log.type,
|
||||
stage: log.stage,
|
||||
@@ -451,7 +448,7 @@ function ServerStatsInner({
|
||||
try {
|
||||
data = await getServerMetricsById(currentHostConfig.id);
|
||||
break;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
retryCount++;
|
||||
if (retryCount === 1) {
|
||||
const initialDelay = totpVerified ? 3000 : 5000;
|
||||
@@ -492,14 +489,15 @@ function ServerStatsInner({
|
||||
}
|
||||
}
|
||||
}, statsConfig.metricsInterval * 1000);
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
const logError = error as ConnectionLogError;
|
||||
console.error("Failed to start metrics polling:", error);
|
||||
setIsLoadingMetrics(false);
|
||||
setHasConnectionError(true);
|
||||
|
||||
if (error?.connectionLogs) {
|
||||
error.connectionLogs.forEach((log: any) => {
|
||||
if (logError.connectionLogs) {
|
||||
logError.connectionLogs.forEach((log) => {
|
||||
addLog({
|
||||
type: log.type,
|
||||
stage: log.stage,
|
||||
@@ -511,7 +509,10 @@ function ServerStatsInner({
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error?.message || t("serverStats.connectionFailed"),
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("serverStats.connectionFailed"),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -566,15 +567,6 @@ function ServerStatsInner({
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const isFileManagerAlreadyOpen = React.useMemo(() => {
|
||||
if (!currentHostConfig) return false;
|
||||
return tabs.some(
|
||||
(tab: TabData) =>
|
||||
tab.type === "file_manager" &&
|
||||
tab.hostConfig?.id === currentHostConfig.id,
|
||||
);
|
||||
}, [tabs, currentHostConfig]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
@@ -775,7 +767,7 @@ function ServerStatsInner({
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
t("serverStats.quickActionError", {
|
||||
name: action.name,
|
||||
@@ -783,7 +775,9 @@ function ServerStatsInner({
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description:
|
||||
error?.message || "Unknown error",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error",
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/dialogs/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/dialogs/SSHAuthDialog.tsx";
|
||||
import { PassphraseDialog } from "@/ui/desktop/navigation/dialogs/PassphraseDialog.tsx";
|
||||
import { WarpgateDialog } from "@/ui/desktop/navigation/dialogs/WarpgateDialog.tsx";
|
||||
import { OPKSSHDialog } from "@/ui/desktop/navigation/dialogs/OPKSSHDialog.tsx";
|
||||
import { HostKeyVerificationDialog } from "@/ui/desktop/navigation/dialogs/HostKeyVerificationDialog.tsx";
|
||||
@@ -40,7 +41,6 @@ import type { TerminalConfig } from "@/types";
|
||||
import { useTheme } from "@/components/theme-provider.tsx";
|
||||
import { useCommandTracker } from "@/ui/hooks/useCommandTracker.ts";
|
||||
import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts";
|
||||
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory.ts";
|
||||
import { useCommandHistory } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
@@ -78,6 +78,11 @@ interface TerminalHandle {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
type HostKeyVerificationData = Omit<
|
||||
React.ComponentProps<typeof HostKeyVerificationDialog>,
|
||||
"isOpen" | "scenario" | "onAccept" | "onReject" | "backgroundColor"
|
||||
>;
|
||||
|
||||
interface SSHTerminalProps {
|
||||
hostConfig: HostConfig;
|
||||
isVisible: boolean;
|
||||
@@ -88,112 +93,10 @@ interface SSHTerminalProps {
|
||||
onTitleChange?: (title: string) => void;
|
||||
initialPath?: string;
|
||||
executeCommand?: string;
|
||||
onOpenFileManager?: () => void;
|
||||
onOpenFileManager?: (path?: string) => void;
|
||||
previewTheme?: string | null;
|
||||
}
|
||||
|
||||
function TerminalContextMenu({
|
||||
x,
|
||||
y,
|
||||
hasSelection,
|
||||
showCopyPaste,
|
||||
showOpenFileManager,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onOpenFileManager,
|
||||
onClose,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
hasSelection: boolean;
|
||||
showCopyPaste: boolean;
|
||||
showOpenFileManager: boolean;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onOpenFileManager: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const menuX = x + 180 > window.innerWidth ? window.innerWidth - 190 : x;
|
||||
const menuY = y + 150 > window.innerHeight ? window.innerHeight - 160 : y;
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | null = null;
|
||||
const timeoutId = setTimeout(() => {
|
||||
const handleClose = (e: MouseEvent) => {
|
||||
if (!menuRef.current?.contains(e.target as Element)) onClose();
|
||||
};
|
||||
const handleRightClick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
};
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", handleClose, true);
|
||||
document.addEventListener("contextmenu", handleRightClick);
|
||||
document.addEventListener("keydown", handleKey);
|
||||
window.addEventListener("blur", onClose);
|
||||
|
||||
cleanup = () => {
|
||||
document.removeEventListener("mousedown", handleClose, true);
|
||||
document.removeEventListener("contextmenu", handleRightClick);
|
||||
document.removeEventListener("keydown", handleKey);
|
||||
window.removeEventListener("blur", onClose);
|
||||
};
|
||||
}, 50);
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
cleanup?.();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const items: { label: string; action: () => void; disabled?: boolean }[] = [];
|
||||
|
||||
if (showCopyPaste) {
|
||||
items.push(
|
||||
{ label: t("terminal.copy"), action: onCopy, disabled: !hasSelection },
|
||||
{ label: t("terminal.paste"), action: onPaste },
|
||||
);
|
||||
}
|
||||
|
||||
if (showOpenFileManager) {
|
||||
items.push({
|
||||
label: t("terminal.openFileManagerHere"),
|
||||
action: onOpenFileManager,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99990]" />
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[180px] z-[99995] overflow-hidden"
|
||||
style={{ left: menuX, top: menuY }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center hover:bg-hover transition-colors first:rounded-t-lg last:rounded-b-lg ${item.disabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (!item.disabled) {
|
||||
item.action();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
function SSHTerminal(
|
||||
{
|
||||
@@ -201,7 +104,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
isVisible,
|
||||
splitScreen = false,
|
||||
onClose,
|
||||
onTitleChange,
|
||||
initialPath,
|
||||
executeCommand,
|
||||
onOpenFileManager,
|
||||
@@ -209,16 +111,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
!(window as { testJWT?: () => string | null }).testJWT
|
||||
) {
|
||||
(window as { testJWT?: () => string | null }).testJWT = () => {
|
||||
const jwt = getCookie("jwt");
|
||||
return jwt;
|
||||
};
|
||||
}
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const commandHistoryContext = useCommandHistory();
|
||||
@@ -288,6 +180,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const [authDialogReason, setAuthDialogReason] = useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
>("no_keyboard");
|
||||
const [showPassphraseDialog, setShowPassphraseDialog] = useState(false);
|
||||
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
|
||||
useState(false);
|
||||
const [warpgateAuthRequired, setWarpgateAuthRequired] = useState(false);
|
||||
@@ -305,19 +198,14 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
} | null>(null);
|
||||
const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
hasSelection: boolean;
|
||||
} | null>(null);
|
||||
const opksshFailedRef = useRef(false);
|
||||
const currentHostIdRef = useRef<number | null>(null);
|
||||
const currentHostConfigRef = useRef<any>(null);
|
||||
const currentHostConfigRef = useRef<HostConfig | null>(null);
|
||||
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<{
|
||||
isOpen: boolean;
|
||||
scenario: "new" | "changed";
|
||||
data: any;
|
||||
data: HostKeyVerificationData;
|
||||
} | null>(null);
|
||||
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
@@ -344,6 +232,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const isReconnectingRef = useRef(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
const wasConnectedRef = useRef(false);
|
||||
const closeAfterDisconnectRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isUnmountingRef.current = false;
|
||||
@@ -353,6 +242,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
reconnectAttempts.current = 0;
|
||||
wasConnectedRef.current = false;
|
||||
isAttachingSessionRef.current = false;
|
||||
closeAfterDisconnectRef.current = false;
|
||||
|
||||
return () => {};
|
||||
}, [hostConfig.id]);
|
||||
@@ -360,7 +250,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const totpTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const activityLoggedRef = useRef(false);
|
||||
const keyHandlerAttachedRef = useRef(false);
|
||||
const [commandHistoryTrackingEnabled, setCommandHistoryTrackingEnabled] =
|
||||
useState<boolean>(
|
||||
() => localStorage.getItem("commandHistoryTracking") === "true",
|
||||
@@ -425,9 +314,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const autocompleteSuggestionsRef = useRef<string[]>([]);
|
||||
const autocompleteSelectedIndexRef = useRef(0);
|
||||
|
||||
const [showHistoryDialog, setShowHistoryDialog] = useState(false);
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
const [showHistoryDialog] = useState(false);
|
||||
const [, setCommandHistory] = useState<string[]>([]);
|
||||
const [, setIsLoadingHistory] = useState(false);
|
||||
|
||||
const setIsLoadingRef = useRef(commandHistoryContext.setIsLoading);
|
||||
const setCommandHistoryContextRef = useRef(
|
||||
@@ -535,12 +424,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const jwtToken = getCookie("jwt");
|
||||
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
|
||||
|
||||
setIsAuthenticated((prev) => {
|
||||
if (prev !== isAuth) {
|
||||
return isAuth;
|
||||
if (!prev) {
|
||||
return true;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
@@ -703,6 +589,32 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
function handlePassphraseSubmit(passphrase: string) {
|
||||
if (webSocketRef.current && terminal) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "reconnect_with_credentials",
|
||||
data: {
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
keyPassword: passphrase,
|
||||
hostConfig: {
|
||||
...hostConfig,
|
||||
keyPassword: passphrase,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
setShowPassphraseDialog(false);
|
||||
setIsConnecting(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePassphraseCancel() {
|
||||
setShowPassphraseDialog(false);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
function scheduleNotify(cols: number, rows: number) {
|
||||
if (!(cols > 0 && rows > 0)) return;
|
||||
pendingSizeRef.current = { cols, rows };
|
||||
@@ -802,12 +714,19 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
},
|
||||
refresh: () => hardRefresh(),
|
||||
openFileManager: () => {
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: "get_cwd" }));
|
||||
} else {
|
||||
onOpenFileManager?.("/");
|
||||
}
|
||||
},
|
||||
}),
|
||||
[terminal],
|
||||
);
|
||||
|
||||
function getUseRightClickCopyPaste() {
|
||||
return getCookie("rightClickCopyPaste") === "true";
|
||||
return getCookie("rightClickCopyPaste") !== "false";
|
||||
}
|
||||
|
||||
function attemptReconnection() {
|
||||
@@ -873,21 +792,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
const jwtToken = getCookie("jwt");
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.warn("Reconnection cancelled - no authentication token");
|
||||
isReconnectingRef.current = false;
|
||||
updateConnectionError(t("terminal.authenticationRequired"));
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("terminal.authenticationRequired"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (terminal && hostConfig) {
|
||||
if (!isAttachingSessionRef.current) {
|
||||
terminal.clear();
|
||||
@@ -922,17 +826,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
const jwtToken = getCookie("jwt");
|
||||
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.error("No JWT token available for WebSocket connection");
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
updateConnectionError("Authentication required");
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let baseWsUrl: string;
|
||||
|
||||
if (isDev) {
|
||||
@@ -1053,7 +946,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const savedSessionId = persistenceEnabled
|
||||
? localStorage.getItem(`termix_session_${tabId}`)
|
||||
: null;
|
||||
if (savedSessionId && !isReconnectingRef.current) {
|
||||
if (savedSessionId) {
|
||||
sessionIdRef.current = savedSessionId;
|
||||
isAttachingSessionRef.current = true;
|
||||
|
||||
@@ -1304,13 +1197,27 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
} else if (msg.type === "session_ended") {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
} else if (msg.type === "disconnected") {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
shouldNotReconnectRef.current = true;
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
if (wasConnectedRef.current) {
|
||||
wasConnectedRef.current = false;
|
||||
setShowDisconnectedOverlay(true);
|
||||
setShowDisconnectedOverlay(false);
|
||||
if (onClose && !closeAfterDisconnectRef.current) {
|
||||
closeAfterDisconnectRef.current = true;
|
||||
isUnmountingRef.current = true;
|
||||
window.setTimeout(onClose, 0);
|
||||
}
|
||||
} else if (!connectionErrorRef.current) {
|
||||
updateConnectionError(
|
||||
msg.message || t("terminal.connectionRejected"),
|
||||
@@ -1334,6 +1241,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}, 180000);
|
||||
} else if (msg.type === "totp_retry") {
|
||||
// Existing prompt remains visible while the backend asks for another code.
|
||||
} else if (msg.type === "password_required") {
|
||||
setTotpRequired(true);
|
||||
setTotpPrompt(msg.prompt || t("common.password"));
|
||||
@@ -1502,6 +1410,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
} else if (msg.type === "cwd") {
|
||||
onOpenFileManager?.(msg.path as string);
|
||||
} else if (msg.type === "passphrase_required") {
|
||||
setShowPassphraseDialog(true);
|
||||
setIsConnecting(false);
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
} else if (msg.type === "host_key_verification_required") {
|
||||
setHostKeyVerification({
|
||||
isOpen: true,
|
||||
@@ -1691,8 +1608,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1752,6 +1667,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
async function writeTextToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (window.electronClipboard) {
|
||||
await window.electronClipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
@@ -1778,6 +1697,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
async function readTextFromClipboard(): Promise<string> {
|
||||
try {
|
||||
if (window.electronClipboard) {
|
||||
return window.electronClipboard.readText();
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
@@ -1870,7 +1792,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(hostConfig.terminalConfig as any),
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
|
||||
let themeColors;
|
||||
@@ -1954,7 +1876,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(hostConfig.terminalConfig as any),
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
|
||||
const fontConfig = TERMINAL_FONTS.find(
|
||||
@@ -2049,29 +1971,26 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const element = xtermRef.current;
|
||||
const handleContextMenu = (e: MouseEvent) => {
|
||||
if (getUseRightClickCopyPaste()) {
|
||||
if (e.ctrlKey && onOpenFileManager) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onOpenFileManager();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isElectron() && getUseRightClickCopyPaste()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
const text = terminal.getSelection();
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => terminal.clearSelection());
|
||||
writeTextToClipboard(text).then(() => terminal.clearSelection());
|
||||
} else {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
readTextFromClipboard().then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!onOpenFileManager) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
hasSelection: terminal.hasSelection(),
|
||||
});
|
||||
};
|
||||
element?.addEventListener("contextmenu", handleContextMenu);
|
||||
|
||||
@@ -2111,7 +2030,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(hostConfig.terminalConfig as any),
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
if (config.backspaceMode !== "control-h") return;
|
||||
|
||||
@@ -2236,8 +2155,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
if (
|
||||
((e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) ||
|
||||
(e.metaKey && !e.ctrlKey && !e.altKey) ||
|
||||
((e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey) ||
|
||||
(e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
@@ -2629,6 +2547,19 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
<PassphraseDialog
|
||||
isOpen={showPassphraseDialog}
|
||||
onSubmit={handlePassphraseSubmit}
|
||||
onCancel={handlePassphraseCancel}
|
||||
hostInfo={{
|
||||
ip: hostConfig.ip,
|
||||
port: hostConfig.port,
|
||||
username: hostConfig.username,
|
||||
name: hostConfig.name,
|
||||
}}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
<WarpgateDialog
|
||||
isOpen={warpgateAuthRequired}
|
||||
url={warpgateAuthUrl}
|
||||
@@ -2764,29 +2695,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
position={autocompletePosition}
|
||||
onSelect={handleAutocompleteSelect}
|
||||
/>
|
||||
|
||||
{contextMenu && (
|
||||
<TerminalContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
hasSelection={contextMenu.hasSelection}
|
||||
showCopyPaste={getUseRightClickCopyPaste()}
|
||||
showOpenFileManager={!!onOpenFileManager}
|
||||
onCopy={async () => {
|
||||
const selection = terminal?.getSelection();
|
||||
if (selection) {
|
||||
await writeTextToClipboard(selection);
|
||||
terminal?.clearSelection();
|
||||
}
|
||||
}}
|
||||
onPaste={async () => {
|
||||
const text = await readTextFromClipboard();
|
||||
if (text) terminal?.paste(text);
|
||||
}}
|
||||
onOpenFileManager={() => onOpenFileManager?.()}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { TerminalTheme } from "@/constants/terminal-themes.ts";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
TERMINAL_FONTS,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TunnelViewer } from "@/ui/desktop/apps/features/tunnel/TunnelViewer.tsx";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getTunnelStatuses,
|
||||
subscribeTunnelStatuses,
|
||||
connectTunnel,
|
||||
disconnectTunnel,
|
||||
cancelTunnel,
|
||||
@@ -17,7 +16,6 @@ import type {
|
||||
} from "../../../types/index.js";
|
||||
|
||||
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
||||
const [tunnelStatuses, setTunnelStatuses] = useState<
|
||||
@@ -110,11 +108,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTunnelStatuses = useCallback(async () => {
|
||||
const statusData = await getTunnelStatuses();
|
||||
setTunnelStatuses(statusData);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 5000);
|
||||
@@ -137,10 +130,10 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
}, [fetchHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTunnelStatuses();
|
||||
const interval = setInterval(fetchTunnelStatuses, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTunnelStatuses]);
|
||||
return subscribeTunnelStatuses(setTunnelStatuses, () => {
|
||||
// The view remains usable if the stream reconnects or is unavailable.
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleHosts.length > 0 && visibleHosts[0]) {
|
||||
@@ -168,6 +161,15 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
|
||||
const tunnelConfig = {
|
||||
name: tunnelName,
|
||||
scope: tunnel.scope || "s2s",
|
||||
mode: tunnel.mode || tunnel.tunnelType || "remote",
|
||||
bindHost: tunnel.bindHost,
|
||||
targetHost: tunnel.targetHost,
|
||||
tunnelType:
|
||||
tunnel.tunnelType ||
|
||||
(tunnel.mode === "local" || tunnel.mode === "remote"
|
||||
? tunnel.mode
|
||||
: "remote"),
|
||||
sourceHostId: host.id,
|
||||
tunnelIndex: tunnelIndex,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
@@ -221,8 +223,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
} else if (action === "cancel") {
|
||||
await cancelTunnel(tunnelName);
|
||||
}
|
||||
|
||||
await fetchTunnelStatuses();
|
||||
} catch (error) {
|
||||
console.error("Tunnel action failed:", {
|
||||
action,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import type { TunnelStatus } from "@/types/index.js";
|
||||
import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Play,
|
||||
Square,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type TunnelInlineControlsProps = {
|
||||
status?: TunnelStatus;
|
||||
loading?: boolean;
|
||||
onStart?: () => void;
|
||||
onStop?: () => void;
|
||||
startDisabled?: boolean;
|
||||
startDisabledReason?: string;
|
||||
};
|
||||
|
||||
function getStatusKind(status?: TunnelStatus) {
|
||||
const value = status?.status?.toUpperCase() || "DISCONNECTED";
|
||||
|
||||
if (value === "CONNECTED") return "connected";
|
||||
if (value === "ERROR" || value === "FAILED") return "error";
|
||||
if (
|
||||
value === "CONNECTING" ||
|
||||
value === "DISCONNECTING" ||
|
||||
value === "RETRYING" ||
|
||||
value === "WAITING"
|
||||
) {
|
||||
return "connecting";
|
||||
}
|
||||
|
||||
return "disconnected";
|
||||
}
|
||||
|
||||
function getStatusTitle(
|
||||
status: TunnelStatus | undefined,
|
||||
statusText: string,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
) {
|
||||
if (!status) return statusText;
|
||||
|
||||
const details = [];
|
||||
if (status.reason) details.push(status.reason);
|
||||
if (status.retryCount && status.maxRetries) {
|
||||
details.push(
|
||||
t("tunnels.attempt", {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (status.nextRetryIn) {
|
||||
details.push(
|
||||
t("tunnels.nextRetryIn", {
|
||||
seconds: status.nextRetryIn,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (status.errorType && !status.reason) details.push(status.errorType);
|
||||
|
||||
return details.length > 0 ? details.join("\n") : statusText;
|
||||
}
|
||||
|
||||
export function TunnelInlineControls({
|
||||
status,
|
||||
loading = false,
|
||||
onStart,
|
||||
onStop,
|
||||
startDisabled,
|
||||
startDisabledReason,
|
||||
}: TunnelInlineControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const kind = getStatusKind(status);
|
||||
const isDisconnected = kind === "disconnected";
|
||||
const statusText =
|
||||
kind === "connected"
|
||||
? t("tunnels.connected")
|
||||
: kind === "connecting"
|
||||
? t("tunnels.connecting")
|
||||
: kind === "error"
|
||||
? t("tunnels.error")
|
||||
: t("tunnels.disconnected");
|
||||
const title = getStatusTitle(status, statusText, t);
|
||||
|
||||
const statusClass =
|
||||
kind === "connected"
|
||||
? "text-green-600 dark:text-green-400 bg-green-500/10 border-green-500/20"
|
||||
: kind === "connecting"
|
||||
? "text-blue-600 dark:text-blue-400 bg-blue-500/10 border-blue-500/20"
|
||||
: kind === "error"
|
||||
? "text-red-600 dark:text-red-400 bg-red-500/10 border-red-500/20"
|
||||
: "text-muted-foreground bg-muted/30 border-border";
|
||||
|
||||
const statusIcon =
|
||||
kind === "connected" ? (
|
||||
<Wifi className="h-3 w-3" />
|
||||
) : kind === "connecting" ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : kind === "error" ? (
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<span
|
||||
className={`inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-medium ${statusClass}`}
|
||||
title={title}
|
||||
>
|
||||
{statusIcon}
|
||||
{statusText}
|
||||
</span>
|
||||
{loading ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-8 px-3 text-xs text-muted-foreground border-border"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{isDisconnected ? t("tunnels.start") : t("tunnels.stop")}
|
||||
</Button>
|
||||
) : isDisconnected ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onStart}
|
||||
disabled={startDisabled}
|
||||
title={startDisabled ? startDisabledReason : undefined}
|
||||
className="h-8 px-3 text-xs text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.start")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onStop}
|
||||
className="h-8 px-3 text-xs text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.stop")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Tunnel } from "@/ui/desktop/apps/features/tunnel/Tunnel.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSSHHosts } from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
@@ -33,7 +35,29 @@ export function TunnelManager({
|
||||
}: TunnelManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const { tabs, addTab, setCurrentTab, updateTab } = useTabs();
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const isElectron =
|
||||
typeof window !== "undefined" && window.electronAPI?.isElectron === true;
|
||||
|
||||
const openC2SPresets = React.useCallback(() => {
|
||||
const profileTab = tabs.find((tab) => tab.type === "user_profile");
|
||||
if (profileTab) {
|
||||
updateTab(profileTab.id, {
|
||||
initialTab: "c2s-tunnels",
|
||||
_updateTimestamp: Date.now(),
|
||||
});
|
||||
setCurrentTab(profileTab.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = addTab({
|
||||
type: "user_profile",
|
||||
title: t("profile.title"),
|
||||
initialTab: "c2s-tunnels",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}, [addTab, setCurrentTab, t, tabs, updateTab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
@@ -107,6 +131,11 @@ export function TunnelManager({
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
{isElectron && (
|
||||
<Button size="sm" variant="outline" onClick={openC2SPresets}>
|
||||
{t("tunnels.manageClientTunnels")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
@@ -127,10 +156,10 @@ export function TunnelManager({
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-foreground-subtle text-lg">
|
||||
{t("tunnel.noTunnelsConfigured")}
|
||||
{t("tunnels.noTunnelsConfigured")}
|
||||
</p>
|
||||
<p className="text-foreground-subtle text-sm mt-2">
|
||||
{t("tunnel.configureTunnelsInHostSettings")}
|
||||
{t("tunnels.configureTunnelsInHostSettings")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TunnelMode } from "@/types/index.js";
|
||||
|
||||
type TunnelModeSelectorProps = {
|
||||
mode: TunnelMode;
|
||||
scope: "client" | "server";
|
||||
onChange: (mode: TunnelMode) => void;
|
||||
};
|
||||
|
||||
export function TunnelModeSelector({
|
||||
mode,
|
||||
scope,
|
||||
onChange,
|
||||
}: TunnelModeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const options: Array<{
|
||||
value: TunnelMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: "local",
|
||||
label: t("tunnels.typeLocal"),
|
||||
description:
|
||||
scope === "client"
|
||||
? t("tunnels.typeClientLocalDesc")
|
||||
: t("tunnels.typeServerLocalDesc"),
|
||||
},
|
||||
{
|
||||
value: "remote",
|
||||
label: t("tunnels.typeRemote"),
|
||||
description:
|
||||
scope === "client"
|
||||
? t("tunnels.typeClientRemoteDesc")
|
||||
: t("tunnels.typeServerRemoteDesc"),
|
||||
},
|
||||
{
|
||||
value: "dynamic",
|
||||
label: t("tunnels.typeDynamic"),
|
||||
description:
|
||||
scope === "client"
|
||||
? t("tunnels.typeClientDynamicDesc")
|
||||
: t("tunnels.typeDynamicDesc"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-start gap-3 rounded-md border bg-card p-3 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
value={option.value}
|
||||
checked={mode === option.value}
|
||||
onChange={() => onChange(option.value)}
|
||||
className="mt-0.5 w-4 h-4 text-primary border-input focus:ring-ring"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{option.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -172,7 +172,7 @@ export function TunnelObject({
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.disconnect")}
|
||||
{t("tunnels.stop")}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
@@ -198,7 +198,7 @@ export function TunnelObject({
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.connect")}
|
||||
{t("tunnels.start")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -406,7 +406,7 @@ export function TunnelObject({
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.disconnect")}
|
||||
{t("tunnels.stop")}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
@@ -432,7 +432,7 @@ export function TunnelObject({
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.connect")}
|
||||
{t("tunnels.start")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { TunnelMode } from "@/types/index.js";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
options?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
export function getTunnelTypeForMode(mode: TunnelMode): "local" | "remote" {
|
||||
return mode === "remote" ? "remote" : "local";
|
||||
}
|
||||
|
||||
export function getTunnelPortLabels(
|
||||
scope: "client" | "server",
|
||||
mode: TunnelMode,
|
||||
t: Translate,
|
||||
) {
|
||||
if (scope === "client") {
|
||||
return {
|
||||
sourcePortLabel:
|
||||
mode === "remote" ? t("tunnels.remotePort") : t("tunnels.localPort"),
|
||||
endpointPortLabel:
|
||||
mode === "remote" ? t("tunnels.localPort") : t("tunnels.remotePort"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
sourcePortLabel: t("tunnels.currentHostPort"),
|
||||
endpointPortLabel: t("tunnels.endpointPort"),
|
||||
};
|
||||
}
|
||||
|
||||
export function getTunnelModeDescription(
|
||||
scope: "client" | "server",
|
||||
mode: TunnelMode,
|
||||
ports: {
|
||||
sourcePort: string | number;
|
||||
endpointPort: string | number;
|
||||
},
|
||||
t: Translate,
|
||||
) {
|
||||
if (scope === "client") {
|
||||
if (mode === "dynamic") {
|
||||
return t("tunnels.forwardDescriptionClientDynamic", {
|
||||
sourcePort: ports.sourcePort,
|
||||
});
|
||||
}
|
||||
if (mode === "local") {
|
||||
return t("tunnels.forwardDescriptionClientLocal", ports);
|
||||
}
|
||||
return t("tunnels.forwardDescriptionClientRemote", ports);
|
||||
}
|
||||
|
||||
if (mode === "dynamic") {
|
||||
return t("tunnels.forwardDescriptionServerDynamic", {
|
||||
sourcePort: ports.sourcePort,
|
||||
});
|
||||
}
|
||||
if (mode === "local") {
|
||||
return t("tunnels.forwardDescriptionServerLocal", ports);
|
||||
}
|
||||
return t("tunnels.forwardDescriptionServerRemote", ports);
|
||||
}
|
||||
@@ -95,6 +95,7 @@ export function CredentialEditor({
|
||||
|
||||
setFolders(uniqueFolders);
|
||||
} catch {
|
||||
// Keep the editor usable even if credentials cannot be loaded.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -92,15 +92,6 @@ export function CredentialSelector({
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onValueChange(null);
|
||||
if (onCredentialSelect) {
|
||||
onCredentialSelect(null);
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.selectCredential")}</FormLabel>
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
Terminal,
|
||||
Copy,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import {
|
||||
@@ -97,6 +98,7 @@ export function CredentialsManager({
|
||||
>([]);
|
||||
const [selectedHostId, setSelectedHostId] = useState<string>("");
|
||||
const [deployLoading, setDeployLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [hostComboboxOpen, setHostComboboxOpen] = useState(false);
|
||||
const [showCopyCommandDialog, setShowCopyCommandDialog] = useState(false);
|
||||
const [copyCommandCredential, setCopyCommandCredential] =
|
||||
@@ -124,16 +126,29 @@ export function CredentialsManager({
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCredentials = async () => {
|
||||
const fetchCredentials = async (options: { showLoading?: boolean } = {}) => {
|
||||
const { showLoading = true } = options;
|
||||
try {
|
||||
setLoading(true);
|
||||
if (showLoading) setLoading(true);
|
||||
const data = await getCredentials();
|
||||
setCredentials(data);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError(t("credentials.failedToFetchCredentials"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshCredentials = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchCredentials({ showLoading: false }),
|
||||
fetchHosts(),
|
||||
]);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -471,7 +486,15 @@ export function CredentialsManager({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={fetchCredentials} variant="outline" size="sm">
|
||||
<Button
|
||||
onClick={handleRefreshCredentials}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("credentials.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -523,7 +546,15 @@ export function CredentialsManager({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={fetchCredentials} variant="outline" size="sm">
|
||||
<Button
|
||||
onClick={handleRefreshCredentials}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("credentials.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { CredentialGeneralTabProps } from "./shared/tab-types";
|
||||
|
||||
export function CredentialGeneralTab({
|
||||
form,
|
||||
folders,
|
||||
tagInput,
|
||||
setTagInput,
|
||||
folderDropdownOpen,
|
||||
|
||||
@@ -144,9 +144,21 @@ export function HostManager({
|
||||
lastProcessedHostIdRef.current = undefined;
|
||||
};
|
||||
|
||||
const handleFormSubmit = () => {
|
||||
const handleFormSubmit = (savedHost?: SSHHost) => {
|
||||
ignoreNextHostConfigChangeRef.current = true;
|
||||
const savedHostId = editingHost?.id;
|
||||
const isUpdatingHost = Boolean(editingHost?.id && savedHost?.id);
|
||||
|
||||
if (isUpdatingHost) {
|
||||
setEditingHost((current) => ({ ...current, ...savedHost }) as SSHHost);
|
||||
setIsAddingHost(false);
|
||||
lastProcessedHostIdRef.current = savedHost.id;
|
||||
if (updateTab && currentTabId !== undefined) {
|
||||
updateTab(currentTabId, { hostConfig: savedHost });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const savedHostId = savedHost?.id || editingHost?.id;
|
||||
setEditingHost(null);
|
||||
setIsAddingHost(false);
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import { Textarea } from "@/components/ui/textarea.tsx";
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import {
|
||||
@@ -33,10 +19,8 @@ import {
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
createSSHHost,
|
||||
getCredentials,
|
||||
@@ -45,86 +29,18 @@ import {
|
||||
enableAutoStart,
|
||||
disableAutoStart,
|
||||
getSnippets,
|
||||
getRoles,
|
||||
getUserList,
|
||||
getUserInfo,
|
||||
shareHost,
|
||||
getHostAccess,
|
||||
revokeHostAccess,
|
||||
getSSHHostById,
|
||||
notifyHostCreatedOrUpdated,
|
||||
getGuacamoleSettings,
|
||||
type Role,
|
||||
type AccessRecord,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { githubLight } from "@uiw/codemirror-theme-github";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { useTheme } from "@/components/theme-provider.tsx";
|
||||
import type { StatsConfig } from "@/types/stats-widgets.ts";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts";
|
||||
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command.tsx";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover.tsx";
|
||||
import { Slider } from "@/components/ui/slider.tsx";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
TERMINAL_FONTS,
|
||||
CURSOR_STYLES,
|
||||
BELL_STYLES,
|
||||
FAST_SCROLL_MODIFIERS,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
} from "@/constants/terminal-themes.ts";
|
||||
import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
|
||||
import type { TerminalConfig, SSHHost, Credential } from "@/types";
|
||||
import {
|
||||
Plus,
|
||||
X,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Save,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
Users,
|
||||
Shield,
|
||||
Clock,
|
||||
UserCircle,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
|
||||
import { DEFAULT_TERMINAL_CONFIG } from "@/constants/terminal-themes.ts";
|
||||
import type { SSHHost, SSHHostData, Credential } from "@/types";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { HostGeneralTab } from "./tabs/HostGeneralTab";
|
||||
import { HostTerminalTab } from "./tabs/HostTerminalTab";
|
||||
import { HostDockerTab } from "./tabs/HostDockerTab";
|
||||
@@ -136,18 +52,24 @@ import { HostSharingTab } from "./tabs/HostSharingTab";
|
||||
import { HostRemoteDesktopTab } from "./tabs/HostRemoteDesktopTab";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
interface SSHManagerHostEditorProps {
|
||||
editingHost?: SSHHost | null;
|
||||
onFormSubmit?: (updatedHost?: SSHHost) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
function isValidIPv4(value: string) {
|
||||
const parts = value.split(".");
|
||||
return (
|
||||
parts.length === 4 &&
|
||||
parts.every((part) => {
|
||||
if (!/^\d+$/.test(part)) return false;
|
||||
const parsed = Number(part);
|
||||
return parsed >= 0 && parsed <= 255;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function HostManagerEditor({
|
||||
editingHost,
|
||||
onFormSubmit,
|
||||
@@ -162,7 +84,6 @@ export function HostManagerEditor({
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const editorTheme = isDarkMode ? oneDark : githubLight;
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [snippets, setSnippets] = useState<
|
||||
@@ -220,16 +141,7 @@ export function HostManagerEditor({
|
||||
),
|
||||
].sort();
|
||||
|
||||
const uniqueConfigurations = [
|
||||
...new Set(
|
||||
hostsData
|
||||
.filter((host) => host.name && host.name.trim() !== "")
|
||||
.map((host) => host.name),
|
||||
),
|
||||
].sort();
|
||||
|
||||
setFolders(uniqueFolders);
|
||||
setSshConfigurations(uniqueConfigurations);
|
||||
} catch (error) {
|
||||
console.error("Host manager operation failed:", error);
|
||||
}
|
||||
@@ -273,6 +185,25 @@ export function HostManagerEditor({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const tunnelConnectionSchema = z.object({
|
||||
scope: z.enum(["s2s", "c2s"]).default("s2s").optional(),
|
||||
mode: z.enum(["local", "remote", "dynamic"]).default("remote").optional(),
|
||||
tunnelType: z.enum(["local", "remote"]).default("remote").optional(),
|
||||
bindHost: z.string().optional(),
|
||||
sourcePort: z.coerce.number().min(1).max(65535),
|
||||
endpointPort: z.coerce.number().min(1).max(65535),
|
||||
endpointHost: z.string().optional(),
|
||||
targetHost: z.string().optional(),
|
||||
endpointPassword: z.string().optional(),
|
||||
endpointKey: z.string().optional(),
|
||||
endpointKeyPassword: z.string().optional(),
|
||||
endpointAuthType: z.string().optional(),
|
||||
endpointKeyType: z.string().optional(),
|
||||
maxRetries: z.coerce.number().min(0).max(100).default(3),
|
||||
retryInterval: z.coerce.number().min(1).max(3600).default(10),
|
||||
autoStart: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
connectionType: z.enum(["ssh", "rdp", "vnc", "telnet"]).default("ssh"),
|
||||
@@ -304,27 +235,8 @@ export function HostManagerEditor({
|
||||
.optional(),
|
||||
enableTerminal: z.boolean().default(true),
|
||||
enableTunnel: z.boolean().default(true),
|
||||
tunnelConnections: z
|
||||
.array(
|
||||
z.object({
|
||||
tunnelType: z
|
||||
.enum(["local", "remote"])
|
||||
.default("remote")
|
||||
.optional(),
|
||||
sourcePort: z.coerce.number().min(1).max(65535),
|
||||
endpointPort: z.coerce.number().min(1).max(65535),
|
||||
endpointHost: z.string().min(1),
|
||||
endpointPassword: z.string().optional(),
|
||||
endpointKey: z.string().optional(),
|
||||
endpointKeyPassword: z.string().optional(),
|
||||
endpointAuthType: z.string().optional(),
|
||||
endpointKeyType: z.string().optional(),
|
||||
maxRetries: z.coerce.number().min(0).max(100).default(3),
|
||||
retryInterval: z.coerce.number().min(1).max(3600).default(10),
|
||||
autoStart: z.boolean().default(false),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
tunnelConnections: z.array(tunnelConnectionSchema).default([]),
|
||||
serverTunnels: z.array(tunnelConnectionSchema).default([]),
|
||||
enableFileManager: z.boolean().default(true),
|
||||
defaultPath: z.string().optional(),
|
||||
statsConfig: z
|
||||
@@ -485,6 +397,38 @@ export function HostManagerEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const serverTunnels = data.serverTunnels;
|
||||
|
||||
serverTunnels.forEach((tunnel, index) => {
|
||||
if (
|
||||
(tunnel.scope || "s2s") === "s2s" &&
|
||||
(!tunnel.endpointHost || tunnel.endpointHost.trim() === "")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t(
|
||||
"tunnels.endpointSshConfigRequired",
|
||||
"Endpoint SSH configuration is required",
|
||||
),
|
||||
path: ["serverTunnels", index, "endpointHost"],
|
||||
});
|
||||
}
|
||||
|
||||
const currentHostIp =
|
||||
tunnel.mode === "remote" ? tunnel.targetHost : tunnel.bindHost;
|
||||
if (currentHostIp && !isValidIPv4(currentHostIp)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("tunnels.invalidCurrentHostIp"),
|
||||
path: [
|
||||
"serverTunnels",
|
||||
index,
|
||||
tunnel.mode === "remote" ? "targetHost" : "bindHost",
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (data.authType === "none") {
|
||||
return;
|
||||
}
|
||||
@@ -496,7 +440,9 @@ export function HostManagerEditor({
|
||||
if (data.authType === "password") {
|
||||
if (
|
||||
!data.password ||
|
||||
(typeof data.password === "string" && data.password.trim() === "")
|
||||
(typeof data.password === "string" &&
|
||||
data.password.trim() === "" &&
|
||||
data.password !== "existing_password")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -534,7 +480,7 @@ export function HostManagerEditor({
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
resolver: zodResolver(formSchema) as unknown as Resolver<FormData>,
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
connectionType: "ssh" as const,
|
||||
@@ -562,6 +508,7 @@ export function HostManagerEditor({
|
||||
showServerStatsInSidebar: false,
|
||||
defaultPath: "/",
|
||||
tunnelConnections: [],
|
||||
serverTunnels: [],
|
||||
jumpHosts: [],
|
||||
quickActions: [],
|
||||
statsConfig: DEFAULT_STATS_CONFIG,
|
||||
@@ -635,7 +582,11 @@ export function HostManagerEditor({
|
||||
return false;
|
||||
|
||||
if (authTab === "password") {
|
||||
if (!watchedFields.password || watchedFields.password.trim() === "")
|
||||
if (
|
||||
!watchedFields.password ||
|
||||
(watchedFields.password !== "existing_password" &&
|
||||
watchedFields.password.trim() === "")
|
||||
)
|
||||
return false;
|
||||
} else if (authTab === "key") {
|
||||
if (!watchedFields.key || !watchedFields.keyType) return false;
|
||||
@@ -709,7 +660,7 @@ export function HostManagerEditor({
|
||||
useEffect(() => {
|
||||
if (editingHost) {
|
||||
const cleanedHost = { ...editingHost };
|
||||
if ((cleanedHost as any).connectionType === "ssh") {
|
||||
if (cleanedHost.connectionType === "ssh") {
|
||||
if (cleanedHost.credentialId && cleanedHost.key) {
|
||||
cleanedHost.key = undefined;
|
||||
cleanedHost.keyPassword = undefined;
|
||||
@@ -752,7 +703,7 @@ export function HostManagerEditor({
|
||||
parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig };
|
||||
|
||||
const formData: Partial<FormData> = {
|
||||
connectionType: (cleanedHost as any).connectionType || "ssh",
|
||||
connectionType: cleanedHost.connectionType || "ssh",
|
||||
name: cleanedHost.name || "",
|
||||
ip: cleanedHost.ip || "",
|
||||
port: cleanedHost.port || 22,
|
||||
@@ -779,8 +730,10 @@ export function HostManagerEditor({
|
||||
enableFileManager: Boolean(cleanedHost.enableFileManager),
|
||||
defaultPath: cleanedHost.defaultPath || "/",
|
||||
tunnelConnections: Array.isArray(cleanedHost.tunnelConnections)
|
||||
? cleanedHost.tunnelConnections.map((conn: any) => ({
|
||||
? cleanedHost.tunnelConnections.map((conn) => ({
|
||||
...conn,
|
||||
scope: conn.scope || "s2s",
|
||||
mode: conn.mode || conn.tunnelType || "remote",
|
||||
tunnelType: conn.tunnelType || "remote",
|
||||
}))
|
||||
: [],
|
||||
@@ -822,11 +775,11 @@ export function HostManagerEditor({
|
||||
? cleanedHost.portKnockSequence
|
||||
: [],
|
||||
enableDocker: Boolean(cleanedHost.enableDocker),
|
||||
domain: (cleanedHost as any).domain || "",
|
||||
security: (cleanedHost as any).security || "any",
|
||||
ignoreCert: (cleanedHost as any).ignoreCert ?? true,
|
||||
domain: cleanedHost.domain || "",
|
||||
security: cleanedHost.security || "any",
|
||||
ignoreCert: cleanedHost.ignoreCert ?? true,
|
||||
guacamoleConfig: (() => {
|
||||
const cfg = (cleanedHost as any).guacamoleConfig;
|
||||
const cfg = cleanedHost.guacamoleConfig;
|
||||
if (!cfg) return {};
|
||||
if (typeof cfg === "string") {
|
||||
try {
|
||||
@@ -858,7 +811,8 @@ export function HostManagerEditor({
|
||||
formData.password = cleanedHost.password;
|
||||
}
|
||||
} else if (defaultAuthType === "password") {
|
||||
formData.password = cleanedHost.password || "";
|
||||
formData.password =
|
||||
cleanedHost.password || (editingHost.id ? "existing_password" : "");
|
||||
} else if (defaultAuthType === "key") {
|
||||
formData.key = editingHost.id ? "existing_key" : editingHost.key;
|
||||
formData.keyPassword = cleanedHost.keyPassword || "";
|
||||
@@ -877,6 +831,10 @@ export function HostManagerEditor({
|
||||
formData.credentialId = cleanedHost.credentialId;
|
||||
}
|
||||
|
||||
const serverTunnels = Array.isArray(formData.tunnelConnections)
|
||||
? formData.tunnelConnections.filter((tunnel) => tunnel.scope !== "c2s")
|
||||
: [];
|
||||
formData.serverTunnels = serverTunnels;
|
||||
form.reset(formData as FormData);
|
||||
} else {
|
||||
setAuthTab("password");
|
||||
@@ -901,6 +859,7 @@ export function HostManagerEditor({
|
||||
enableFileManager: true,
|
||||
defaultPath: "/",
|
||||
tunnelConnections: [],
|
||||
serverTunnels: [],
|
||||
jumpHosts: [],
|
||||
quickActions: [],
|
||||
statsConfig: DEFAULT_STATS_CONFIG,
|
||||
@@ -942,15 +901,27 @@ export function HostManagerEditor({
|
||||
data.name = `${data.username}@${data.ip}`;
|
||||
}
|
||||
|
||||
const submitData: Partial<SSHHost> = {
|
||||
type SubmitHostData = SSHHostData & { serverTunnels?: unknown };
|
||||
const submitData: SubmitHostData = {
|
||||
...data,
|
||||
};
|
||||
delete submitData.serverTunnels;
|
||||
|
||||
(submitData as any).connectionType = data.connectionType;
|
||||
(submitData as any).domain = data.domain;
|
||||
(submitData as any).security = data.security;
|
||||
(submitData as any).ignoreCert = data.ignoreCert;
|
||||
(submitData as any).guacamoleConfig = data.guacamoleConfig;
|
||||
const serverTunnels = Array.isArray(data.serverTunnels)
|
||||
? data.serverTunnels
|
||||
: [];
|
||||
const serverTunnelConnections = serverTunnels.map((tunnel) => ({
|
||||
...tunnel,
|
||||
scope: "s2s" as const,
|
||||
}));
|
||||
|
||||
submitData.tunnelConnections = serverTunnelConnections;
|
||||
|
||||
submitData.connectionType = data.connectionType;
|
||||
submitData.domain = data.domain;
|
||||
submitData.security = data.security;
|
||||
submitData.ignoreCert = data.ignoreCert;
|
||||
submitData.guacamoleConfig = data.guacamoleConfig;
|
||||
|
||||
if (data.connectionType !== "ssh") {
|
||||
submitData.authType = "none";
|
||||
@@ -960,8 +931,8 @@ export function HostManagerEditor({
|
||||
submitData.credentialId = undefined;
|
||||
submitData.tunnelConnections = [];
|
||||
submitData.jumpHosts = [];
|
||||
(submitData as any).useSocks5 = false;
|
||||
(submitData as any).socks5ProxyChain = [];
|
||||
submitData.useSocks5 = false;
|
||||
submitData.socks5ProxyChain = [];
|
||||
submitData.forceKeyboardInteractive = false;
|
||||
submitData.enableTunnel = false;
|
||||
submitData.enableFileManager = false;
|
||||
@@ -996,17 +967,24 @@ export function HostManagerEditor({
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
data.authType === "password" &&
|
||||
data.password === "existing_password"
|
||||
) {
|
||||
delete submitData.password;
|
||||
}
|
||||
|
||||
let savedHost;
|
||||
if (editingHost && editingHost.id) {
|
||||
savedHost = await updateSSHHost(editingHost.id, submitData as any);
|
||||
savedHost = await updateSSHHost(editingHost.id, submitData);
|
||||
toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name }));
|
||||
} else {
|
||||
savedHost = await createSSHHost(submitData as any);
|
||||
savedHost = await createSSHHost(submitData);
|
||||
toast.success(t("hosts.hostAddedSuccessfully", { name: data.name }));
|
||||
}
|
||||
|
||||
if (savedHost && savedHost.id && data.tunnelConnections) {
|
||||
const hasAutoStartTunnels = data.tunnelConnections.some(
|
||||
if (savedHost && savedHost.id && serverTunnelConnections) {
|
||||
const hasAutoStartTunnels = serverTunnelConnections.some(
|
||||
(tunnel) => tunnel.autoStart,
|
||||
);
|
||||
|
||||
@@ -1019,7 +997,7 @@ export function HostManagerEditor({
|
||||
error,
|
||||
);
|
||||
toast.warning(
|
||||
t("hosts.autoStartEnableFailed", { name: data.name }),
|
||||
t("tunnels.autoStartEnableFailed", { name: data.name }),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -1039,10 +1017,6 @@ export function HostManagerEditor({
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
|
||||
if (savedHost?.id) {
|
||||
notifyHostCreatedOrUpdated(savedHost.id);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
@@ -1098,6 +1072,7 @@ export function HostManagerEditor({
|
||||
connectionType: "general",
|
||||
enableTunnel: "tunnel",
|
||||
tunnelConnections: "tunnel",
|
||||
serverTunnels: "tunnel",
|
||||
enableFileManager: "file_manager",
|
||||
defaultPath: "file_manager",
|
||||
statsConfig: "statistics",
|
||||
@@ -1207,91 +1182,6 @@ export function HostManagerEditor({
|
||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||
}, [keyTypeDropdownOpen]);
|
||||
|
||||
const [sshConfigDropdownOpen, setSshConfigDropdownOpen] = useState<{
|
||||
[key: number]: boolean;
|
||||
}>({});
|
||||
const sshConfigInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>(
|
||||
{},
|
||||
);
|
||||
const sshConfigDropdownRefs = useRef<{
|
||||
[key: number]: HTMLDivElement | null;
|
||||
}>({});
|
||||
|
||||
const getFilteredSshConfigs = (index: number) => {
|
||||
const value = form.watch(`tunnelConnections.${index}.endpointHost`);
|
||||
|
||||
const currentHostId = editingHost?.id;
|
||||
|
||||
let filtered = sshConfigurations;
|
||||
|
||||
if (currentHostId) {
|
||||
const currentHostName = hosts.find((h) => h.id === currentHostId)?.name;
|
||||
if (currentHostName) {
|
||||
filtered = sshConfigurations.filter(
|
||||
(config) => config !== currentHostName,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const currentHostName =
|
||||
form.watch("name") || `${form.watch("username")}@${form.watch("ip")}`;
|
||||
filtered = sshConfigurations.filter(
|
||||
(config) => config !== currentHostName,
|
||||
);
|
||||
}
|
||||
|
||||
if (value) {
|
||||
filtered = filtered.filter((config) =>
|
||||
config.toLowerCase().includes(value.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const handleSshConfigClick = (config: string, index: number) => {
|
||||
form.setValue(`tunnelConnections.${index}.endpointHost`, config, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleSshConfigClickOutside(event: MouseEvent) {
|
||||
const openDropdowns = Object.keys(sshConfigDropdownOpen).filter(
|
||||
(key) => sshConfigDropdownOpen[parseInt(key)],
|
||||
);
|
||||
|
||||
openDropdowns.forEach((indexStr: string) => {
|
||||
const index = parseInt(indexStr);
|
||||
if (
|
||||
sshConfigDropdownRefs.current[index] &&
|
||||
!sshConfigDropdownRefs.current[index]?.contains(
|
||||
event.target as Node,
|
||||
) &&
|
||||
sshConfigInputRefs.current[index] &&
|
||||
!sshConfigInputRefs.current[index]?.contains(event.target as Node)
|
||||
) {
|
||||
setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const hasOpenDropdowns = Object.values(sshConfigDropdownOpen).some(
|
||||
(open) => open,
|
||||
);
|
||||
|
||||
if (hasOpenDropdowns) {
|
||||
document.addEventListener("mousedown", handleSshConfigClickOutside);
|
||||
} else {
|
||||
document.removeEventListener("mousedown", handleSshConfigClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleSshConfigClickOutside);
|
||||
};
|
||||
}, [sshConfigDropdownOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full min-h-0 w-full relative">
|
||||
<SimpleLoader
|
||||
@@ -1490,12 +1380,8 @@ export function HostManagerEditor({
|
||||
<TabsContent value="tunnel">
|
||||
<HostTunnelTab
|
||||
form={form}
|
||||
sshConfigDropdownOpen={sshConfigDropdownOpen}
|
||||
setSshConfigDropdownOpen={setSshConfigDropdownOpen}
|
||||
sshConfigInputRefs={sshConfigInputRefs}
|
||||
sshConfigDropdownRefs={sshConfigDropdownRefs}
|
||||
getFilteredSshConfigs={getFilteredSshConfigs}
|
||||
handleSshConfigClick={handleSshConfigClick}
|
||||
hosts={hosts}
|
||||
editingHost={editingHost}
|
||||
t={t}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -1549,18 +1435,20 @@ export function HostManagerEditor({
|
||||
<footer className="shrink-0 w-full pb-0">
|
||||
<Separator className="p-0.25" />
|
||||
{!editingHost?.isShared && !isSubmitting && (
|
||||
<Button
|
||||
className="translate-y-2"
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!isFormValid}
|
||||
>
|
||||
{editingHost
|
||||
? editingHost.id
|
||||
? t("hosts.updateHost")
|
||||
: t("hosts.cloneHost")
|
||||
: t("hosts.addHost")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 translate-y-2">
|
||||
<Button type="submit" variant="outline" disabled={!isFormValid}>
|
||||
{editingHost
|
||||
? editingHost.id
|
||||
? t("hosts.updateHost")
|
||||
: t("hosts.cloneHost")
|
||||
: t("hosts.addHost")}
|
||||
</Button>
|
||||
{form.formState.isDirty && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("common.unsavedChanges")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
@@ -80,7 +80,6 @@ import {
|
||||
Globe,
|
||||
FolderOpen,
|
||||
Share2,
|
||||
Users,
|
||||
ArrowDownUp,
|
||||
Container,
|
||||
Link,
|
||||
@@ -92,6 +91,7 @@ import {
|
||||
Eye,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
SSHHost,
|
||||
@@ -102,6 +102,7 @@ import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts";
|
||||
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||
import { FolderEditDialog } from "@/ui/desktop/apps/host-manager/dialogs/FolderEditDialog.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
const INITIAL_HOSTS_PER_FOLDER = 12;
|
||||
|
||||
@@ -123,6 +124,7 @@ export function HostManagerViewer({
|
||||
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||
const [editingFolderName, setEditingFolderName] = useState("");
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(
|
||||
new Map(),
|
||||
);
|
||||
@@ -138,7 +140,7 @@ export function HostManagerViewer({
|
||||
new Set(),
|
||||
);
|
||||
const [bulkUpdating, setBulkUpdating] = useState(false);
|
||||
const { getStatus } = useServerStatus();
|
||||
const { getStatus, refreshStatuses } = useServerStatus();
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -165,9 +167,10 @@ export function HostManagerViewer({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchHosts = async () => {
|
||||
const fetchHosts = async (options: { showLoading?: boolean } = {}) => {
|
||||
const { showLoading = true } = options;
|
||||
try {
|
||||
setLoading(true);
|
||||
if (showLoading) setLoading(true);
|
||||
const data = await getSSHHosts();
|
||||
|
||||
const cleanedHosts = data.map((host) => {
|
||||
@@ -192,7 +195,7 @@ export function HostManagerViewer({
|
||||
} catch {
|
||||
setError(t("hosts.failedToLoadHosts"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -209,6 +212,20 @@ export function HostManagerViewer({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshHosts = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchHosts({ showLoading: false }),
|
||||
fetchFolderMetadata(),
|
||||
refreshServerPolling(),
|
||||
refreshStatuses(),
|
||||
]);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFolderAppearance = async (
|
||||
folderName: string,
|
||||
color: string,
|
||||
@@ -408,9 +425,7 @@ export function HostManagerViewer({
|
||||
};
|
||||
|
||||
const selectAllHosts = () => {
|
||||
const selectableIds = hosts
|
||||
.filter((h) => !(h as any).isShared)
|
||||
.map((h) => h.id);
|
||||
const selectableIds = hosts.filter((h) => !h.isShared).map((h) => h.id);
|
||||
setSelectedHostIds(new Set(selectableIds));
|
||||
};
|
||||
|
||||
@@ -458,7 +473,7 @@ export function HostManagerViewer({
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
toast.success(t("hosts.fullScreenUrlCopied"));
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToCopyUrl"));
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
@@ -1031,14 +1046,7 @@ export function HostManagerViewer({
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">{t("hosts.loadingHosts")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <SimpleLoader visible={true} message={t("hosts.loadingHosts")} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -1112,7 +1120,15 @@ export function HostManagerViewer({
|
||||
|
||||
<div className="w-px h-6 bg-border mx-2" />
|
||||
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
<Button
|
||||
onClick={handleRefreshHosts}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("hosts.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1221,7 +1237,15 @@ export function HostManagerViewer({
|
||||
|
||||
<div className="w-px h-6 bg-border mx-2" />
|
||||
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
<Button
|
||||
onClick={handleRefreshHosts}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("hosts.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1403,7 +1427,7 @@ export function HostManagerViewer({
|
||||
{selectionMode &&
|
||||
(() => {
|
||||
const selectableIds = folderHosts
|
||||
.filter((h) => !(h as any).isShared)
|
||||
.filter((h) => !h.isShared)
|
||||
.map((h) => h.id);
|
||||
const allSelected =
|
||||
selectableIds.length > 0 &&
|
||||
@@ -1493,15 +1517,12 @@ export function HostManagerViewer({
|
||||
? "ring-2 ring-blue-500 border-blue-500"
|
||||
: "border-input hover:border-blue-400/50"
|
||||
} ${
|
||||
selectionMode && (host as any).isShared
|
||||
selectionMode && host.isShared
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (
|
||||
selectionMode &&
|
||||
!(host as any).isShared
|
||||
) {
|
||||
if (selectionMode && !host.isShared) {
|
||||
toggleHostSelection(host.id);
|
||||
} else {
|
||||
handleEdit(host);
|
||||
@@ -1511,19 +1532,16 @@ export function HostManagerViewer({
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{selectionMode &&
|
||||
!(host as any).isShared && (
|
||||
<Checkbox
|
||||
checked={selectedHostIds.has(
|
||||
host.id,
|
||||
)}
|
||||
onCheckedChange={() =>
|
||||
toggleHostSelection(host.id)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-background border-2 mr-1 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
{selectionMode && !host.isShared && (
|
||||
<Checkbox
|
||||
checked={selectedHostIds.has(host.id)}
|
||||
onCheckedChange={() =>
|
||||
toggleHostSelection(host.id)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-background border-2 mr-1 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
{(() => {
|
||||
const statsConfig = (() => {
|
||||
if (!host.statsConfig) {
|
||||
@@ -1536,7 +1554,7 @@ export function HostManagerViewer({
|
||||
}
|
||||
try {
|
||||
return JSON.parse(host.statsConfig);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
})();
|
||||
@@ -1568,7 +1586,7 @@ export function HostManagerViewer({
|
||||
? `${host.username}@${host.ip}`
|
||||
: host.ip)}
|
||||
</h3>
|
||||
{(host as any).isShared && (
|
||||
{host.isShared && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0 text-violet-500 border-violet-500/50"
|
||||
@@ -1590,7 +1608,7 @@ export function HostManagerViewer({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
{!(host as any).isShared &&
|
||||
{!host.isShared &&
|
||||
host.folder &&
|
||||
host.folder !== "" && (
|
||||
<Tooltip>
|
||||
@@ -1635,7 +1653,7 @@ export function HostManagerViewer({
|
||||
<p>{t("hosts.editHostTooltip")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{!(host as any).isShared && (
|
||||
{!host.isShared && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1723,8 +1741,8 @@ export function HostManagerViewer({
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
{(() => {
|
||||
const connType = (host as any)
|
||||
.connectionType;
|
||||
const connType =
|
||||
host.connectionType;
|
||||
const isRemoteDesktop =
|
||||
connType === "rdp" ||
|
||||
connType === "vnc" ||
|
||||
@@ -1869,8 +1887,7 @@ export function HostManagerViewer({
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(() => {
|
||||
const connType = (host as any)
|
||||
.connectionType;
|
||||
const connType = host.connectionType;
|
||||
if (connType === "rdp") {
|
||||
return (
|
||||
<Badge
|
||||
@@ -1962,8 +1979,7 @@ export function HostManagerViewer({
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1">
|
||||
{(() => {
|
||||
const connType = (host as any)
|
||||
.connectionType;
|
||||
const connType = host.connectionType;
|
||||
const isRemoteDesktop =
|
||||
connType === "rdp" ||
|
||||
connType === "vnc" ||
|
||||
@@ -2267,14 +2283,12 @@ export function HostManagerViewer({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={
|
||||
selectedHostIds.size ===
|
||||
hosts.filter((h) => !(h as any).isShared).length
|
||||
selectedHostIds.size === hosts.filter((h) => !h.isShared).length
|
||||
? deselectAllHosts
|
||||
: selectAllHosts
|
||||
}
|
||||
>
|
||||
{selectedHostIds.size ===
|
||||
hosts.filter((h) => !(h as any).isShared).length
|
||||
{selectedHostIds.size === hosts.filter((h) => !h.isShared).length
|
||||
? t("hosts.deselectAll")
|
||||
: t("hosts.selectAll")}
|
||||
</Button>
|
||||
|
||||
@@ -32,23 +32,19 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
Plus,
|
||||
X,
|
||||
Upload,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Plus, X, ArrowRight, Loader2 } from "lucide-react";
|
||||
import type { HostGeneralTabProps } from "./shared/tab-types";
|
||||
import { JumpHostItem } from "./shared/JumpHostItem";
|
||||
import { testProxyConnection } from "@/ui/main-axios";
|
||||
import { toast } from "sonner";
|
||||
import type { ProxyNode } from "@/types";
|
||||
|
||||
type JumpHostSelection = { hostId: number };
|
||||
|
||||
export function HostGeneralTab({
|
||||
form,
|
||||
@@ -76,7 +72,6 @@ export function HostGeneralTab({
|
||||
editorTheme,
|
||||
hosts,
|
||||
editingHost,
|
||||
folders,
|
||||
credentials,
|
||||
t,
|
||||
}: HostGeneralTabProps) {
|
||||
@@ -138,12 +133,12 @@ export function HostGeneralTab({
|
||||
t("hosts.connectionPath") === "Connection Path" ? "You" : "You",
|
||||
];
|
||||
const useSocks5 = form.watch("useSocks5");
|
||||
const jumpHosts = form.watch("jumpHosts") || [];
|
||||
const jumpHosts = (form.watch("jumpHosts") || []) as JumpHostSelection[];
|
||||
|
||||
if (useSocks5) {
|
||||
if (proxyMode === "chain") {
|
||||
const chain = form.watch("socks5ProxyChain") || [];
|
||||
chain.forEach((node: any, i: number) => {
|
||||
const chain = (form.watch("socks5ProxyChain") || []) as ProxyNode[];
|
||||
chain.forEach((node) => {
|
||||
if (node.host) {
|
||||
const typeLabel =
|
||||
node.type === "http" ? "HTTP" : `SOCKS${node.type}`;
|
||||
@@ -160,8 +155,8 @@ export function HostGeneralTab({
|
||||
}
|
||||
|
||||
if (jumpHosts.length > 0 && hosts) {
|
||||
jumpHosts.forEach((jh: any) => {
|
||||
const found = hosts.find((h: any) => h.id === jh.hostId);
|
||||
jumpHosts.forEach((jh) => {
|
||||
const found = hosts.find((h) => h.id === jh.hostId);
|
||||
if (found) {
|
||||
parts.push(`Jump: ${found.name || found.ip}`);
|
||||
}
|
||||
@@ -177,6 +172,9 @@ export function HostGeneralTab({
|
||||
return parts;
|
||||
};
|
||||
|
||||
const socks5ProxyChain = (form.watch("socks5ProxyChain") ||
|
||||
[]) as ProxyNode[];
|
||||
|
||||
return (
|
||||
<div className="pt-2">
|
||||
<FormLabel className="mb-3 font-bold">
|
||||
@@ -224,52 +222,50 @@ export function HostGeneralTab({
|
||||
)}
|
||||
/>
|
||||
|
||||
{connectionType !== "vnc" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => {
|
||||
const isCredentialAuth = authTab === "credential";
|
||||
const credentialId = form.watch("credentialId");
|
||||
const overrideEnabled = form.watch("overrideCredentialUsername");
|
||||
const selectedCredential = credentials.find(
|
||||
(c) => c.id === credentialId,
|
||||
);
|
||||
const shouldDisable =
|
||||
isCredentialAuth &&
|
||||
selectedCredential?.username &&
|
||||
!overrideEnabled;
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => {
|
||||
const isCredentialAuth = authTab === "credential";
|
||||
const credentialId = form.watch("credentialId");
|
||||
const overrideEnabled = form.watch("overrideCredentialUsername");
|
||||
const selectedCredential = credentials.find(
|
||||
(c) => c.id === credentialId,
|
||||
);
|
||||
const shouldDisable =
|
||||
isCredentialAuth &&
|
||||
selectedCredential?.username &&
|
||||
!overrideEnabled;
|
||||
|
||||
return (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t("hosts.username")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.username")}
|
||||
disabled={shouldDisable}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
if (
|
||||
isCredentialAuth &&
|
||||
selectedCredential &&
|
||||
!selectedCredential.username &&
|
||||
e.target.value.trim() !== ""
|
||||
) {
|
||||
form.setValue("overrideCredentialUsername", true);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t("hosts.username")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.username")}
|
||||
disabled={shouldDisable}
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
if (
|
||||
isCredentialAuth &&
|
||||
selectedCredential &&
|
||||
!selectedCredential.username &&
|
||||
e.target.value.trim() !== ""
|
||||
) {
|
||||
form.setValue("overrideCredentialUsername", true);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4 mt-3">
|
||||
<FormField
|
||||
@@ -1210,200 +1206,180 @@ export function HostGeneralTab({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(form.watch("socks5ProxyChain") || []).length ===
|
||||
0 && (
|
||||
{socks5ProxyChain.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground text-center p-4 border rounded-lg border-dashed">
|
||||
{t("hosts.noProxyNodes")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(form.watch("socks5ProxyChain") || []).map(
|
||||
(node: any, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 border rounded-lg space-y-3 relative"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("hosts.proxyNode")} {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
form.setValue(
|
||||
"socks5ProxyChain",
|
||||
currentChain.filter(
|
||||
(_: any, i: number) => i !== index,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("hosts.socks5Host")}</FormLabel>
|
||||
<Input
|
||||
placeholder={t("placeholders.socks5Host")}
|
||||
value={node.host}
|
||||
onChange={(e) => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
host: e.target.value,
|
||||
};
|
||||
form.setValue(
|
||||
"socks5ProxyChain",
|
||||
newChain,
|
||||
);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
host: e.target.value.trim(),
|
||||
};
|
||||
form.setValue(
|
||||
"socks5ProxyChain",
|
||||
newChain,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("hosts.socks5Port")}</FormLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("placeholders.socks5Port")}
|
||||
value={node.port}
|
||||
onChange={(e) => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
port: parseInt(e.target.value) || 1080,
|
||||
};
|
||||
form.setValue(
|
||||
"socks5ProxyChain",
|
||||
newChain,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{socks5ProxyChain.map((node, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 border rounded-lg space-y-3 relative"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t("hosts.proxyNode")} {index + 1}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
form.setValue(
|
||||
"socks5ProxyChain",
|
||||
currentChain.filter(
|
||||
(_node: ProxyNode, i: number) =>
|
||||
i !== index,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("hosts.proxyType")}</FormLabel>
|
||||
<Select
|
||||
value={String(node.type)}
|
||||
onValueChange={(value) => {
|
||||
<FormLabel>{t("hosts.socks5Host")}</FormLabel>
|
||||
<Input
|
||||
placeholder={t("placeholders.socks5Host")}
|
||||
value={node.host}
|
||||
onChange={(e) => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
type:
|
||||
value === "http"
|
||||
? ("http" as const)
|
||||
: (parseInt(value) as 4 | 5),
|
||||
host: e.target.value,
|
||||
};
|
||||
form.setValue("socks5ProxyChain", newChain);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="4">
|
||||
{t("hosts.socks4")}
|
||||
</SelectItem>
|
||||
<SelectItem value="5">
|
||||
{t("hosts.socks5")}
|
||||
</SelectItem>
|
||||
<SelectItem value="http">
|
||||
{t("hosts.httpConnect")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onBlur={(e) => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
host: e.target.value.trim(),
|
||||
};
|
||||
form.setValue("socks5ProxyChain", newChain);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t("hosts.socks5Username")}{" "}
|
||||
{t("hosts.optional")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder={t("hosts.username")}
|
||||
value={node.username || ""}
|
||||
onChange={(e) => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
username: e.target.value,
|
||||
};
|
||||
form.setValue(
|
||||
"socks5ProxyChain",
|
||||
newChain,
|
||||
);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
username: e.target.value.trim(),
|
||||
};
|
||||
form.setValue(
|
||||
"socks5ProxyChain",
|
||||
newChain,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t("hosts.socks5Password")}{" "}
|
||||
{t("hosts.optional")}
|
||||
</FormLabel>
|
||||
<PasswordInput
|
||||
placeholder={t("hosts.password")}
|
||||
value={node.password || ""}
|
||||
onChange={(e) => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
password: e.target.value,
|
||||
};
|
||||
form.setValue(
|
||||
"socks5ProxyChain",
|
||||
newChain,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("hosts.socks5Port")}</FormLabel>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("placeholders.socks5Port")}
|
||||
value={node.port}
|
||||
onChange={(e) => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
port: parseInt(e.target.value) || 1080,
|
||||
};
|
||||
form.setValue("socks5ProxyChain", newChain);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("hosts.proxyType")}</FormLabel>
|
||||
<Select
|
||||
value={String(node.type)}
|
||||
onValueChange={(value) => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
type:
|
||||
value === "http"
|
||||
? ("http" as const)
|
||||
: (parseInt(value) as 4 | 5),
|
||||
};
|
||||
form.setValue("socks5ProxyChain", newChain);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="4">
|
||||
{t("hosts.socks4")}
|
||||
</SelectItem>
|
||||
<SelectItem value="5">
|
||||
{t("hosts.socks5")}
|
||||
</SelectItem>
|
||||
<SelectItem value="http">
|
||||
{t("hosts.httpConnect")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t("hosts.socks5Username")}{" "}
|
||||
{t("hosts.optional")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
placeholder={t("hosts.username")}
|
||||
value={node.username || ""}
|
||||
onChange={(e) => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
username: e.target.value,
|
||||
};
|
||||
form.setValue("socks5ProxyChain", newChain);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
username: e.target.value.trim(),
|
||||
};
|
||||
form.setValue("socks5ProxyChain", newChain);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{t("hosts.socks5Password")}{" "}
|
||||
{t("hosts.optional")}
|
||||
</FormLabel>
|
||||
<PasswordInput
|
||||
placeholder={t("hosts.password")}
|
||||
value={node.password || ""}
|
||||
onChange={(e) => {
|
||||
const currentChain =
|
||||
form.watch("socks5ProxyChain") || [];
|
||||
const newChain = [...currentChain];
|
||||
newChain[index] = {
|
||||
...newChain[index],
|
||||
password: e.target.value,
|
||||
};
|
||||
form.setValue("socks5ProxyChain", newChain);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -23,22 +23,22 @@ import {
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import type { HostRemoteDesktopTabProps } from "./shared/tab-types";
|
||||
|
||||
type RemoteDesktopForm = HostRemoteDesktopTabProps["form"];
|
||||
|
||||
function GuacField({
|
||||
form,
|
||||
path,
|
||||
label,
|
||||
description,
|
||||
type = "text",
|
||||
t,
|
||||
}: {
|
||||
form: any;
|
||||
form: RemoteDesktopForm;
|
||||
path: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type?: "text" | "number" | "password" | "switch";
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const fieldName = `guacamoleConfig.${path}` as any;
|
||||
const fieldName = `guacamoleConfig.${path}` as never;
|
||||
|
||||
if (type === "switch") {
|
||||
return (
|
||||
@@ -120,13 +120,13 @@ function GuacSelect({
|
||||
options,
|
||||
placeholder,
|
||||
}: {
|
||||
form: any;
|
||||
form: RemoteDesktopForm;
|
||||
path: string;
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const fieldName = `guacamoleConfig.${path}` as any;
|
||||
const fieldName = `guacamoleConfig.${path}` as never;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
@@ -254,14 +254,12 @@ export function HostRemoteDesktopTab({
|
||||
path="width"
|
||||
label={t("hosts.width")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="height"
|
||||
label={t("hosts.height")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
{!isTelnet && (
|
||||
@@ -271,7 +269,6 @@ export function HostRemoteDesktopTab({
|
||||
path="dpi"
|
||||
label={t("hosts.dpi")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
<GuacSelect
|
||||
form={form}
|
||||
@@ -288,7 +285,6 @@ export function HostRemoteDesktopTab({
|
||||
path="force-lossless"
|
||||
label={t("hosts.forceLossless")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -318,14 +314,12 @@ export function HostRemoteDesktopTab({
|
||||
form={form}
|
||||
path="font-name"
|
||||
label={t("hosts.guacFontName")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="font-size"
|
||||
label={t("hosts.guacFontSize")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
<GuacSelect
|
||||
form={form}
|
||||
@@ -363,7 +357,6 @@ export function HostRemoteDesktopTab({
|
||||
path="disable-audio"
|
||||
label={t("hosts.disableAudio")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
{isRDP && (
|
||||
<GuacField
|
||||
@@ -371,7 +364,6 @@ export function HostRemoteDesktopTab({
|
||||
path="enable-audio-input"
|
||||
label={t("hosts.enableAudioInput")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</AccordionContent>
|
||||
@@ -388,70 +380,60 @@ export function HostRemoteDesktopTab({
|
||||
path="enable-wallpaper"
|
||||
label={t("hosts.enableWallpaper")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-theming"
|
||||
label={t("hosts.enableTheming")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-font-smoothing"
|
||||
label={t("hosts.enableFontSmoothing")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-full-window-drag"
|
||||
label={t("hosts.enableFullWindowDrag")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-desktop-composition"
|
||||
label={t("hosts.enableDesktopComposition")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-menu-animations"
|
||||
label={t("hosts.enableMenuAnimations")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-bitmap-caching"
|
||||
label={t("hosts.disableBitmapCaching")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-offscreen-caching"
|
||||
label={t("hosts.disableOffscreenCaching")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-glyph-caching"
|
||||
label={t("hosts.disableGlyphCaching")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-gfx"
|
||||
label={t("hosts.enableGfx")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -467,54 +449,46 @@ export function HostRemoteDesktopTab({
|
||||
path="enable-printing"
|
||||
label={t("hosts.enablePrinting")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-drive"
|
||||
label={t("hosts.enableDrive")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="drive-name"
|
||||
label={t("hosts.driveName")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="drive-path"
|
||||
label={t("hosts.drivePath")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="create-drive-path"
|
||||
label={t("hosts.createDrivePath")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-download"
|
||||
label={t("hosts.disableDownload")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-upload"
|
||||
label={t("hosts.disableUpload")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="enable-touch"
|
||||
label={t("hosts.enableTouch")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -529,20 +503,17 @@ export function HostRemoteDesktopTab({
|
||||
form={form}
|
||||
path="client-name"
|
||||
label={t("hosts.clientName")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="console"
|
||||
label={t("hosts.consoleSession")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="initial-program"
|
||||
label={t("hosts.initialProgram")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacSelect
|
||||
form={form}
|
||||
@@ -566,7 +537,6 @@ export function HostRemoteDesktopTab({
|
||||
form={form}
|
||||
path="timezone"
|
||||
label={t("hosts.timezone")}
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -581,33 +551,28 @@ export function HostRemoteDesktopTab({
|
||||
form={form}
|
||||
path="gateway-hostname"
|
||||
label={t("hosts.gatewayHostname")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="gateway-port"
|
||||
label={t("hosts.gatewayPort")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="gateway-username"
|
||||
label={t("hosts.gatewayUsername")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="gateway-password"
|
||||
label={t("hosts.gatewayPassword")}
|
||||
type="password"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="gateway-domain"
|
||||
label={t("hosts.gatewayDomain")}
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -622,19 +587,16 @@ export function HostRemoteDesktopTab({
|
||||
form={form}
|
||||
path="remote-app"
|
||||
label={t("hosts.remoteAppProgram")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="remote-app-dir"
|
||||
label={t("hosts.remoteAppDir")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="remote-app-args"
|
||||
label={t("hosts.remoteAppArgs")}
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -660,14 +622,12 @@ export function HostRemoteDesktopTab({
|
||||
path="disable-copy"
|
||||
label={t("hosts.disableCopy")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="disable-paste"
|
||||
label={t("hosts.disablePaste")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -692,14 +652,12 @@ export function HostRemoteDesktopTab({
|
||||
path="swap-red-blue"
|
||||
label={t("hosts.swapRedBlue")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="read-only"
|
||||
label={t("hosts.readOnly")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -713,41 +671,35 @@ export function HostRemoteDesktopTab({
|
||||
form={form}
|
||||
path="recording-path"
|
||||
label={t("hosts.recordingPath")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="recording-name"
|
||||
label={t("hosts.recordingName")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="create-recording-path"
|
||||
label={t("hosts.createRecordingPath")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="recording-exclude-output"
|
||||
label={t("hosts.excludeOutput")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="recording-exclude-mouse"
|
||||
label={t("hosts.excludeMouse")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="recording-include-keys"
|
||||
label={t("hosts.includeKeys")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -761,33 +713,28 @@ export function HostRemoteDesktopTab({
|
||||
path="wol-send-packet"
|
||||
label={t("hosts.sendWolPacket")}
|
||||
type="switch"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="wol-mac-addr"
|
||||
label={t("hosts.wolMacAddr")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="wol-broadcast-addr"
|
||||
label={t("hosts.wolBroadcastAddr")}
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="wol-udp-port"
|
||||
label={t("hosts.wolUdpPort")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
<GuacField
|
||||
form={form}
|
||||
path="wol-wait-time"
|
||||
label={t("hosts.wolWaitTime")}
|
||||
type="number"
|
||||
t={t}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
import type { SSHHost } from "@/types";
|
||||
import type { HostSharingTabProps } from "./shared/tab-types";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -64,15 +63,10 @@ interface User {
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
interface HostSharingTabProps {
|
||||
hostId: number | undefined;
|
||||
isNewHost: boolean;
|
||||
}
|
||||
|
||||
export function HostSharingTab({
|
||||
hostId,
|
||||
isNewHost,
|
||||
}: SharingTabContentProps): React.ReactElement {
|
||||
}: HostSharingTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
@@ -81,7 +75,7 @@ export function HostSharingTab({
|
||||
const [selectedRoleId, setSelectedRoleId] = React.useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [permissionLevel, setPermissionLevel] = React.useState("view");
|
||||
const permissionLevel = "view";
|
||||
const [expiresInHours, setExpiresInHours] = React.useState<string>("");
|
||||
|
||||
const [roles, setRoles] = React.useState<Role[]>([]);
|
||||
@@ -204,7 +198,7 @@ export function HostSharingTab({
|
||||
setSelectedRoleId(null);
|
||||
setExpiresInHours("");
|
||||
loadAccessList();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("rbac.failedToShare"));
|
||||
}
|
||||
};
|
||||
@@ -225,7 +219,7 @@ export function HostSharingTab({
|
||||
await revokeHostAccess(hostId, accessId);
|
||||
toast.success(t("rbac.accessRevokedSuccessfully"));
|
||||
loadAccessList();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("rbac.failedToRevokeAccess"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { HostStatisticsTabProps } from "./shared/tab-types";
|
||||
import { QuickActionItem } from "./shared/QuickActionItem";
|
||||
|
||||
|
||||
@@ -40,9 +40,6 @@ import { cn } from "@/lib/utils.ts";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
TERMINAL_FONTS,
|
||||
CURSOR_STYLES,
|
||||
BELL_STYLES,
|
||||
FAST_SCROLL_MODIFIERS,
|
||||
} from "@/constants/terminal-themes.ts";
|
||||
import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
|
||||
import type { HostTerminalTabProps } from "./shared/tab-types";
|
||||
@@ -51,7 +48,7 @@ import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
|
||||
export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
|
||||
const [snippetPopoverOpen, setSnippetPopoverOpen] = React.useState(false);
|
||||
const { setPreviewTerminalTheme } = useTabs() as any;
|
||||
const { setPreviewTerminalTheme } = useTabs();
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<FormField
|
||||
|
||||
@@ -9,18 +9,226 @@ import { Input } from "@/components/ui/input.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import { TunnelInlineControls } from "@/ui/desktop/apps/features/tunnel/TunnelInlineControls.tsx";
|
||||
import { TunnelModeSelector } from "@/ui/desktop/apps/features/tunnel/TunnelModeSelector.tsx";
|
||||
import {
|
||||
getTunnelModeDescription,
|
||||
getTunnelPortLabels,
|
||||
getTunnelTypeForMode,
|
||||
} from "@/ui/desktop/apps/features/tunnel/tunnel-form-utils.ts";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import {
|
||||
connectTunnel,
|
||||
disconnectTunnel,
|
||||
subscribeTunnelStatuses,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { SSHHost, TunnelConfig, TunnelStatus } from "@/types/index.js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { HostTunnelTabProps } from "./shared/tab-types";
|
||||
|
||||
export function HostTunnelTab({
|
||||
form,
|
||||
sshConfigDropdownOpen,
|
||||
setSshConfigDropdownOpen,
|
||||
sshConfigInputRefs,
|
||||
sshConfigDropdownRefs,
|
||||
getFilteredSshConfigs,
|
||||
handleSshConfigClick,
|
||||
hosts,
|
||||
editingHost,
|
||||
t,
|
||||
}: HostTunnelTabProps) {
|
||||
const { tabs, addTab, setCurrentTab, updateTab } = useTabs();
|
||||
const [tunnelStatuses, setTunnelStatuses] = useState<
|
||||
Record<string, TunnelStatus>
|
||||
>({});
|
||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const previousTunnelStatusesRef = useRef<Record<string, TunnelStatus>>({});
|
||||
const supportsC2S =
|
||||
typeof window !== "undefined" && window.electronAPI?.isElectron === true;
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeTunnelStatuses(setTunnelStatuses, () => {
|
||||
// The form should stay usable if the tunnel status stream reconnects.
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const previousStatuses = previousTunnelStatusesRef.current;
|
||||
|
||||
for (const [tunnelName, status] of Object.entries(tunnelStatuses)) {
|
||||
const previous = previousStatuses[tunnelName];
|
||||
const statusChanged =
|
||||
previous?.status !== status.status ||
|
||||
previous?.reason !== status.reason ||
|
||||
previous?.retryCount !== status.retryCount ||
|
||||
previous?.nextRetryIn !== status.nextRetryIn;
|
||||
|
||||
if (!statusChanged) continue;
|
||||
|
||||
console.info("[tunnels] Server tunnel status changed", {
|
||||
tunnelName,
|
||||
status,
|
||||
});
|
||||
|
||||
const statusValue = status.status?.toUpperCase();
|
||||
const hasFailureDetail =
|
||||
statusValue === "ERROR" ||
|
||||
statusValue === "FAILED" ||
|
||||
(Boolean(status.errorType) &&
|
||||
Boolean(status.reason) &&
|
||||
previous?.reason !== status.reason);
|
||||
|
||||
if (hasFailureDetail) {
|
||||
const message = status.reason || t("tunnels.manualControlError");
|
||||
console.error("[tunnels] Server tunnel failed", {
|
||||
tunnelName,
|
||||
status,
|
||||
});
|
||||
toast.error(message, {
|
||||
id: `server-tunnel-error-${tunnelName}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
previousTunnelStatusesRef.current = tunnelStatuses;
|
||||
}, [t, tunnelStatuses]);
|
||||
|
||||
const openC2SPresets = () => {
|
||||
const profileTab = tabs.find((tab) => tab.type === "user_profile");
|
||||
if (profileTab) {
|
||||
updateTab(profileTab.id, {
|
||||
initialTab: "c2s-tunnels",
|
||||
_updateTimestamp: Date.now(),
|
||||
});
|
||||
setCurrentTab(profileTab.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = addTab({
|
||||
type: "user_profile",
|
||||
title: t("profile.title"),
|
||||
initialTab: "c2s-tunnels",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
};
|
||||
|
||||
const getCurrentHost = (): SSHHost | null => {
|
||||
if (!editingHost?.id) return null;
|
||||
return hosts.find((host) => host.id === editingHost.id) || editingHost;
|
||||
};
|
||||
|
||||
const getTunnelName = (host: SSHHost, index: number) => {
|
||||
const tunnel = form.getValues(`serverTunnels.${index}`);
|
||||
return `${host.id}::${index}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||
};
|
||||
|
||||
const handleServerTunnelAction = async (
|
||||
action: "connect" | "disconnect",
|
||||
index: number,
|
||||
) => {
|
||||
const host = getCurrentHost();
|
||||
if (!host?.id) {
|
||||
toast.error(t("tunnels.saveHostBeforeManualControl"));
|
||||
return;
|
||||
}
|
||||
|
||||
const tunnel = form.getValues(`serverTunnels.${index}`);
|
||||
const tunnelName = getTunnelName(host, index);
|
||||
setTunnelActions((current) => ({ ...current, [tunnelName]: true }));
|
||||
console.info(`[tunnels] ${action} server tunnel`, {
|
||||
tunnelName,
|
||||
tunnel,
|
||||
});
|
||||
|
||||
try {
|
||||
if (action === "connect") {
|
||||
const endpointHost = hosts.find(
|
||||
(item) =>
|
||||
item.name === tunnel.endpointHost ||
|
||||
`${item.username}@${item.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
const tunnelConfig: TunnelConfig = {
|
||||
name: tunnelName,
|
||||
scope: "s2s",
|
||||
mode: tunnel.mode || tunnel.tunnelType || "remote",
|
||||
bindHost: tunnel.bindHost,
|
||||
targetHost: tunnel.targetHost,
|
||||
tunnelType:
|
||||
tunnel.tunnelType ||
|
||||
(tunnel.mode === "local" || tunnel.mode === "remote"
|
||||
? tunnel.mode
|
||||
: "remote"),
|
||||
sourceHostId: host.id,
|
||||
tunnelIndex: index,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
sourcePassword:
|
||||
host.authType === "password" ? host.password : undefined,
|
||||
sourceAuthMethod: host.authType,
|
||||
sourceSSHKey: host.authType === "key" ? host.key : undefined,
|
||||
sourceKeyPassword:
|
||||
host.authType === "key" ? host.keyPassword : undefined,
|
||||
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
|
||||
sourceCredentialId: host.credentialId,
|
||||
sourceUserId: host.userId,
|
||||
endpointHost: tunnel.endpointHost,
|
||||
endpointIP: endpointHost?.ip,
|
||||
endpointSSHPort: endpointHost?.port,
|
||||
endpointUsername: endpointHost?.username,
|
||||
endpointPassword:
|
||||
endpointHost?.authType === "password"
|
||||
? endpointHost.password
|
||||
: undefined,
|
||||
endpointAuthMethod: endpointHost?.authType,
|
||||
endpointSSHKey:
|
||||
endpointHost?.authType === "key" ? endpointHost.key : undefined,
|
||||
endpointKeyPassword:
|
||||
endpointHost?.authType === "key"
|
||||
? endpointHost.keyPassword
|
||||
: undefined,
|
||||
endpointKeyType:
|
||||
endpointHost?.authType === "key" ? endpointHost.keyType : undefined,
|
||||
endpointCredentialId: endpointHost?.credentialId,
|
||||
endpointUserId: endpointHost?.userId,
|
||||
sourcePort: tunnel.sourcePort,
|
||||
endpointPort: tunnel.endpointPort,
|
||||
maxRetries: tunnel.maxRetries,
|
||||
retryInterval: tunnel.retryInterval * 1000,
|
||||
autoStart: tunnel.autoStart,
|
||||
isPinned: host.pin,
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
socks5ProxyChain: host.socks5ProxyChain,
|
||||
};
|
||||
await connectTunnel(tunnelConfig);
|
||||
} else {
|
||||
await disconnectTunnel(tunnelName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[tunnels] Failed to ${action} server tunnel`, {
|
||||
tunnelName,
|
||||
error,
|
||||
});
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("tunnels.manualControlError"),
|
||||
);
|
||||
} finally {
|
||||
setTunnelActions((current) => ({ ...current, [tunnelName]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormField
|
||||
@@ -115,7 +323,7 @@ export function HostTunnelTab({
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="mt-3 flex justify-between">
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -126,315 +334,385 @@ export function HostTunnelTab({
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={openC2SPresets}
|
||||
disabled={!supportsC2S}
|
||||
title={
|
||||
supportsC2S ? undefined : t("tunnels.clientTunnelsUnavailable")
|
||||
}
|
||||
>
|
||||
{t("tunnels.manageClientTunnels")}
|
||||
</Button>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tunnelConnections"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("hosts.tunnelConnections")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{field.value.map((connection, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 border rounded-lg bg-muted/50"
|
||||
{!supportsC2S && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("tunnels.clientTunnelsUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
{(() => {
|
||||
type TunnelFieldName = "serverTunnels";
|
||||
|
||||
const removeTunnel = (
|
||||
fieldName: TunnelFieldName,
|
||||
tunnels: unknown[],
|
||||
index: number,
|
||||
) => {
|
||||
form.setValue(
|
||||
fieldName,
|
||||
tunnels.filter((_, i) => i !== index),
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
};
|
||||
|
||||
const addServerTunnel = (tunnels: unknown[]) => {
|
||||
form.setValue(
|
||||
"serverTunnels",
|
||||
[
|
||||
...tunnels,
|
||||
{
|
||||
scope: "s2s",
|
||||
mode: "remote",
|
||||
tunnelType: "remote",
|
||||
bindHost: "",
|
||||
targetHost: "",
|
||||
sourcePort: 22,
|
||||
endpointPort: 224,
|
||||
endpointHost: "",
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: false,
|
||||
},
|
||||
],
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
};
|
||||
|
||||
const renderTunnelCard = (
|
||||
fieldName: TunnelFieldName,
|
||||
index: number,
|
||||
displayIndex: number,
|
||||
tunnels: unknown[],
|
||||
) => {
|
||||
const scope = form.watch(`${fieldName}.${index}.scope`) || "s2s";
|
||||
const mode = form.watch(`${fieldName}.${index}.mode`) || "remote";
|
||||
const isC2S = scope === "c2s";
|
||||
const currentHost = getCurrentHost();
|
||||
const endpointConfigOptions = hosts
|
||||
.filter(
|
||||
(item) =>
|
||||
item.id !== currentHost?.id &&
|
||||
(item.connectionType || "ssh") === "ssh",
|
||||
)
|
||||
.map((item) => item.name || `${item.username}@${item.ip}`)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
const tunnelName = currentHost
|
||||
? getTunnelName(currentHost, index)
|
||||
: "";
|
||||
const tunnelStatus = tunnelName
|
||||
? tunnelStatuses[tunnelName]
|
||||
: undefined;
|
||||
const isTunnelActionLoading = tunnelName
|
||||
? Boolean(tunnelActions[tunnelName])
|
||||
: false;
|
||||
const formScope = isC2S ? "client" : "server";
|
||||
const { sourcePortLabel, endpointPortLabel } =
|
||||
getTunnelPortLabels(formScope, mode, t);
|
||||
const modeDescription = getTunnelModeDescription(
|
||||
formScope,
|
||||
mode,
|
||||
{
|
||||
sourcePort:
|
||||
form.watch(`${fieldName}.${index}.sourcePort`) || "22",
|
||||
endpointPort:
|
||||
form.watch(`${fieldName}.${index}.endpointPort`) || "22",
|
||||
},
|
||||
t,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={index} className="p-4 border rounded-lg bg-muted/50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-bold">
|
||||
{isC2S
|
||||
? t("tunnels.clientTunnel")
|
||||
: t("tunnels.serverTunnel")}{" "}
|
||||
{displayIndex + 1}
|
||||
</h4>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<TunnelInlineControls
|
||||
status={tunnelStatus}
|
||||
loading={isTunnelActionLoading}
|
||||
onStart={() =>
|
||||
handleServerTunnelAction("connect", index)
|
||||
}
|
||||
onStop={() =>
|
||||
handleServerTunnelAction("disconnect", index)
|
||||
}
|
||||
startDisabled={!currentHost}
|
||||
startDisabledReason={t(
|
||||
"tunnels.saveHostBeforeManualControl",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeTunnel(fieldName, tunnels, index)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-bold">
|
||||
{t("hosts.connection")} {index + 1}
|
||||
</h4>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 mb-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`${fieldName}.${index}.mode`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("tunnels.type")}</FormLabel>
|
||||
<FormControl>
|
||||
<TunnelModeSelector
|
||||
mode={field.value || "remote"}
|
||||
scope={isC2S ? "client" : "server"}
|
||||
onChange={(nextMode) => {
|
||||
field.onChange(nextMode);
|
||||
form.setValue(
|
||||
`${fieldName}.${index}.tunnelType`,
|
||||
getTunnelTypeForMode(nextMode),
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{!isC2S && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`${fieldName}.${index}.endpointHost`}
|
||||
render={({ field: endpointHostField }) => (
|
||||
<FormItem className="col-span-12 md:col-span-6">
|
||||
<FormLabel>
|
||||
{t("tunnels.endpointSshConfig")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={endpointHostField.value || ""}
|
||||
onValueChange={endpointHostField.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("placeholders.sshConfig")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{endpointConfigOptions.map((config) => (
|
||||
<SelectItem key={config} value={config}>
|
||||
{config}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{mode !== "dynamic" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`${fieldName}.${index}.endpointPort`}
|
||||
render={({ field: endpointPortField }) => (
|
||||
<FormItem className="col-span-12 md:col-span-6">
|
||||
<FormLabel>{endpointPortLabel}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"placeholders.defaultEndpointPort",
|
||||
)}
|
||||
{...endpointPortField}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{mode === "dynamic" && (
|
||||
<div className="hidden md:block md:col-span-6" />
|
||||
)}
|
||||
{!isC2S && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={
|
||||
mode === "remote"
|
||||
? `${fieldName}.${index}.targetHost`
|
||||
: `${fieldName}.${index}.bindHost`
|
||||
}
|
||||
render={({ field: currentHostIpField }) => (
|
||||
<FormItem className="col-span-12 md:col-span-6">
|
||||
<FormLabel>{t("tunnels.currentHostIp")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.bindLocalhost")}
|
||||
{...currentHostIpField}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`${fieldName}.${index}.sourcePort`}
|
||||
render={({ field: sourcePortField }) => (
|
||||
<FormItem className="col-span-12 md:col-span-6">
|
||||
<FormLabel>{sourcePortLabel}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.defaultPort")}
|
||||
{...sourcePortField}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{modeDescription}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`${fieldName}.${index}.maxRetries`}
|
||||
render={({ field: maxRetriesField }) => (
|
||||
<FormItem className="col-span-12 md:col-span-6">
|
||||
<FormLabel>{t("tunnels.maxRetries")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.maxRetries")}
|
||||
{...maxRetriesField}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("tunnels.maxRetriesDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`${fieldName}.${index}.retryInterval`}
|
||||
render={({ field: retryIntervalField }) => (
|
||||
<FormItem className="col-span-12 md:col-span-6">
|
||||
<FormLabel>{t("tunnels.retryInterval")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.retryInterval")}
|
||||
{...retryIntervalField}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("tunnels.retryIntervalDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!isC2S && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`${fieldName}.${index}.autoStart`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-12">
|
||||
<FormControl>
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border bg-card p-3">
|
||||
<FormLabel>
|
||||
{t("tunnels.autoStartContainer")}
|
||||
</FormLabel>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("tunnels.autoStartContainerDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverTunnels"
|
||||
render={({ field }) => {
|
||||
const serverTunnels = field.value || [];
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<FormLabel className="text-sm">
|
||||
{t("tunnels.serverTunnels")}
|
||||
</FormLabel>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("tunnels.serverTunnelsDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newConnections = field.value.filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
field.onChange(newConnections);
|
||||
}}
|
||||
onClick={() => addServerTunnel(serverTunnels)}
|
||||
>
|
||||
{t("hosts.remove")}
|
||||
{t("tunnels.addServerTunnel")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.tunnelType`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.tunnelType")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="local"
|
||||
checked={field.value === "local"}
|
||||
onChange={() => field.onChange("local")}
|
||||
className="w-4 h-4 text-primary border-input focus:ring-ring"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{t("hosts.tunnelTypeLocal")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hosts.tunnelTypeLocalDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="remote"
|
||||
checked={field.value === "remote"}
|
||||
onChange={() =>
|
||||
field.onChange("remote")
|
||||
}
|
||||
className="w-4 h-4 text-primary border-input focus:ring-ring"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{t("hosts.tunnelTypeRemote")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("hosts.tunnelTypeRemoteDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<FormControl>
|
||||
<div>
|
||||
{serverTunnels.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{serverTunnels.map((_, index) =>
|
||||
renderTunnelCard(
|
||||
"serverTunnels",
|
||||
index,
|
||||
index,
|
||||
serverTunnels,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground rounded-md border border-dashed p-3">
|
||||
{t("tunnels.noServerTunnels")}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.sourcePort`}
|
||||
render={({ field: sourcePortField }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>
|
||||
{t("hosts.sourcePort")}
|
||||
{t("hosts.sourcePortDesc")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.defaultPort")}
|
||||
{...sourcePortField}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.endpointPort`}
|
||||
render={({ field: endpointPortField }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>{t("hosts.endpointPort")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"placeholders.defaultEndpointPort",
|
||||
)}
|
||||
{...endpointPortField}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.endpointHost`}
|
||||
render={({ field: endpointHostField }) => (
|
||||
<FormItem className="col-span-4 relative">
|
||||
<FormLabel>
|
||||
{t("hosts.endpointSshConfig")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
sshConfigInputRefs.current[index] = el;
|
||||
}}
|
||||
placeholder={t("placeholders.sshConfig")}
|
||||
className="min-h-[40px]"
|
||||
autoComplete="off"
|
||||
value={endpointHostField.value}
|
||||
onFocus={() =>
|
||||
setSshConfigDropdownOpen((prev) => ({
|
||||
...prev,
|
||||
[index]: true,
|
||||
}))
|
||||
}
|
||||
onChange={(e) => {
|
||||
endpointHostField.onChange(e);
|
||||
setSshConfigDropdownOpen((prev) => ({
|
||||
...prev,
|
||||
[index]: true,
|
||||
}));
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
endpointHostField.onChange(
|
||||
e.target.value.trim(),
|
||||
);
|
||||
endpointHostField.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
{sshConfigDropdownOpen[index] &&
|
||||
getFilteredSshConfigs(index).length > 0 && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
sshConfigDropdownRefs.current[index] =
|
||||
el;
|
||||
}}
|
||||
className="absolute top-full left-0 z-50 mt-1 w-full bg-canvas border border-input rounded-md shadow-lg max-h-40 overflow-y-auto thin-scrollbar p-1"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1 p-0">
|
||||
{getFilteredSshConfigs(index).map(
|
||||
(config) => (
|
||||
<Button
|
||||
key={config}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
|
||||
onClick={() =>
|
||||
handleSshConfigClick(
|
||||
config,
|
||||
index,
|
||||
)
|
||||
}
|
||||
>
|
||||
{config}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{form.watch(
|
||||
`tunnelConnections.${index}.tunnelType`,
|
||||
) === "local"
|
||||
? t("hosts.tunnelForwardDescriptionLocal", {
|
||||
sourcePort:
|
||||
form.watch(
|
||||
`tunnelConnections.${index}.sourcePort`,
|
||||
) || "22",
|
||||
endpointPort:
|
||||
form.watch(
|
||||
`tunnelConnections.${index}.endpointPort`,
|
||||
) || "224",
|
||||
})
|
||||
: t("hosts.tunnelForwardDescriptionRemote", {
|
||||
sourcePort:
|
||||
form.watch(
|
||||
`tunnelConnections.${index}.sourcePort`,
|
||||
) || "22",
|
||||
endpointPort:
|
||||
form.watch(
|
||||
`tunnelConnections.${index}.endpointPort`,
|
||||
) || "224",
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.maxRetries`}
|
||||
render={({ field: maxRetriesField }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>{t("hosts.maxRetries")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.maxRetries")}
|
||||
{...maxRetriesField}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.maxRetriesDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.retryInterval`}
|
||||
render={({ field: retryIntervalField }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>
|
||||
{t("hosts.retryInterval")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"placeholders.retryInterval",
|
||||
)}
|
||||
{...retryIntervalField}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.retryIntervalDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.autoStart`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>
|
||||
{t("hosts.autoStartContainer")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.autoStartDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
field.onChange([
|
||||
...field.value,
|
||||
{
|
||||
tunnelType: "remote",
|
||||
sourcePort: 22,
|
||||
endpointPort: 224,
|
||||
endpointHost: "",
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: false,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{t("hosts.addConnection")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface HostGeneralTabProps {
|
||||
export interface HostTerminalTabProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
snippets: Array<{ id: number; name: string; content: string }>;
|
||||
t: (key: string) => string;
|
||||
t: (key: string, options?: unknown) => string;
|
||||
}
|
||||
|
||||
export interface HostDockerTabProps {
|
||||
@@ -46,18 +46,8 @@ export interface HostDockerTabProps {
|
||||
|
||||
export interface HostTunnelTabProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
sshConfigDropdownOpen: { [key: number]: boolean };
|
||||
setSshConfigDropdownOpen: React.Dispatch<
|
||||
React.SetStateAction<{ [key: number]: boolean }>
|
||||
>;
|
||||
sshConfigInputRefs: React.MutableRefObject<{
|
||||
[key: number]: HTMLInputElement | null;
|
||||
}>;
|
||||
sshConfigDropdownRefs: React.MutableRefObject<{
|
||||
[key: number]: HTMLDivElement | null;
|
||||
}>;
|
||||
getFilteredSshConfigs: (index: number) => string[];
|
||||
handleSshConfigClick: (config: string, index: number) => void;
|
||||
hosts: SSHHost[];
|
||||
editingHost?: SSHHost | null;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,19 +38,16 @@ import {
|
||||
Trash2,
|
||||
Copy,
|
||||
X,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Loader2,
|
||||
Terminal,
|
||||
LayoutGrid,
|
||||
MonitorCheck,
|
||||
Folder,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
GripVertical,
|
||||
FolderPlus,
|
||||
Settings,
|
||||
MoreVertical,
|
||||
Server,
|
||||
Cloud,
|
||||
Database,
|
||||
@@ -61,7 +58,6 @@ import {
|
||||
HardDrive,
|
||||
Globe,
|
||||
Share2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -184,7 +180,7 @@ export function SSHToolsSidebar({
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||
const [rightClickCopyPaste, setRightClickCopyPaste] = useState<boolean>(
|
||||
() => getCookie("rightClickCopyPaste") === "true",
|
||||
() => getCookie("rightClickCopyPaste") !== "false",
|
||||
);
|
||||
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
@@ -229,7 +225,6 @@ export function SSHToolsSidebar({
|
||||
[],
|
||||
);
|
||||
const [draggedSnippet, setDraggedSnippet] = useState<Snippet | null>(null);
|
||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(() => {
|
||||
const shouldCollapse =
|
||||
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
|
||||
@@ -265,7 +260,7 @@ export function SSHToolsSidebar({
|
||||
const [splitAssignments, setSplitAssignments] = useState<Map<number, number>>(
|
||||
new Map(),
|
||||
);
|
||||
const [previewKey, setPreviewKey] = useState(0);
|
||||
const [, setPreviewKey] = useState(0);
|
||||
const [draggedTabId, setDraggedTabId] = useState<number | null>(null);
|
||||
const [dragOverCellIndex, setDragOverCellIndex] = useState<number | null>(
|
||||
null,
|
||||
@@ -940,25 +935,16 @@ export function SSHToolsSidebar({
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, targetSnippet: Snippet) => {
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
};
|
||||
|
||||
const handleDragEnterFolder = (folderName: string) => {
|
||||
setDragOverFolder(folderName);
|
||||
};
|
||||
|
||||
const handleDragLeaveFolder = () => {
|
||||
setDragOverFolder(null);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, targetSnippet: Snippet) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggedSnippet || draggedSnippet.id === targetSnippet.id) {
|
||||
setDraggedSnippet(null);
|
||||
setDragOverFolder(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -968,7 +954,6 @@ export function SSHToolsSidebar({
|
||||
if (sourceFolder !== targetFolder) {
|
||||
toast.error(t("snippets.reorderSameFolder"));
|
||||
setDraggedSnippet(null);
|
||||
setDragOverFolder(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -985,7 +970,6 @@ export function SSHToolsSidebar({
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1) {
|
||||
setDraggedSnippet(null);
|
||||
setDragOverFolder(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1008,12 +992,10 @@ export function SSHToolsSidebar({
|
||||
}
|
||||
|
||||
setDraggedSnippet(null);
|
||||
setDragOverFolder(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedSnippet(null);
|
||||
setDragOverFolder(null);
|
||||
};
|
||||
|
||||
const handleCreateFolder = () => {
|
||||
@@ -1214,10 +1196,6 @@ export function SSHToolsSidebar({
|
||||
toast.success(t("splitScreen.cleared"));
|
||||
};
|
||||
|
||||
const handleResetToSingle = () => {
|
||||
handleClearSplit();
|
||||
};
|
||||
|
||||
const handleCommandSelect = (command: string) => {
|
||||
if (activeTerminal?.terminalRef?.current?.sendInput) {
|
||||
activeTerminal.terminalRef.current.sendInput(command);
|
||||
@@ -1256,14 +1234,6 @@ export function SSHToolsSidebar({
|
||||
<SidebarGroupLabel className="text-lg font-bold text-foreground">
|
||||
{t("nav.tools")}
|
||||
<div className="absolute right-5 flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSidebarWidth(400)}
|
||||
className="w-[28px] h-[28px]"
|
||||
title={t("common.resetSidebarWidth")}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
@@ -1722,9 +1692,7 @@ export function SSHToolsSidebar({
|
||||
onDragStart={(e) =>
|
||||
handleDragStart(e, snippet)
|
||||
}
|
||||
onDragOver={(e) =>
|
||||
handleDragOver(e, snippet)
|
||||
}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, snippet)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`bg-field border border-input rounded-lg cursor-move hover:shadow-lg hover:border-edge-hover hover:bg-hover-alt transition-all duration-200 p-3 group ${
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LanguageSwitcher } from "@/ui/desktop/user/LanguageSwitcher.tsx";
|
||||
import { toast } from "sonner";
|
||||
@@ -34,15 +28,19 @@ import {
|
||||
saveServerConfig,
|
||||
isElectron,
|
||||
getEmbeddedServerStatus,
|
||||
isEmbeddedMode,
|
||||
} from "../../main-axios.ts";
|
||||
import { ElectronServerConfig as ServerConfigComponent } from "@/ui/desktop/authentication/ElectronServerConfig.tsx";
|
||||
import { ElectronLoginForm } from "@/ui/desktop/authentication/ElectronLoginForm.tsx";
|
||||
|
||||
function getCookie(name: string): string | undefined {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop()?.split(";").shift();
|
||||
function isMissingServerConfigError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(error as Error & { code?: string }).code === "NO_SERVER_CONFIGURED" ||
|
||||
error.message.includes("no-server-configured")
|
||||
);
|
||||
}
|
||||
|
||||
interface ExtendedWindow extends Window {
|
||||
@@ -57,7 +55,6 @@ interface AuthProps extends React.ComponentProps<"div"> {
|
||||
loggedIn: boolean;
|
||||
authLoading: boolean;
|
||||
setDbError: (error: string | null) => void;
|
||||
dbError?: string | null;
|
||||
onAuthSuccess: (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
@@ -74,7 +71,6 @@ export function Auth({
|
||||
loggedIn,
|
||||
authLoading,
|
||||
setDbError,
|
||||
dbError: _dbError,
|
||||
onAuthSuccess,
|
||||
...props
|
||||
}: AuthProps) {
|
||||
@@ -99,7 +95,7 @@ export function Auth({
|
||||
if (window.self !== window.top) {
|
||||
return true;
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -152,49 +148,48 @@ export function Auth({
|
||||
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||
const [dbHealthChecking, setDbHealthChecking] = useState(false);
|
||||
|
||||
const handleElectronAuthSuccess = useCallback(async () => {
|
||||
try {
|
||||
let retries = 5;
|
||||
let meRes = null;
|
||||
while (retries-- > 0) {
|
||||
try {
|
||||
meRes = await getUserInfo();
|
||||
break;
|
||||
} catch (err: any) {
|
||||
const isNoServer =
|
||||
err?.code === "NO_SERVER_CONFIGURED" ||
|
||||
err?.message?.includes("no-server-configured");
|
||||
if (isNoServer && retries > 0) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
const handleElectronAuthSuccess = useCallback(
|
||||
async (previousJwt: string | null) => {
|
||||
try {
|
||||
const cookieReady = await window.electronAPI?.waitForSessionCookie?.(
|
||||
"jwt",
|
||||
currentServerUrl,
|
||||
previousJwt,
|
||||
5000,
|
||||
);
|
||||
if (cookieReady && !cookieReady.success) {
|
||||
throw new Error(
|
||||
cookieReady.error || "Authentication cookie not ready",
|
||||
);
|
||||
}
|
||||
const meRes = await getUserInfo();
|
||||
if (!meRes) throw new Error("Failed to get user info");
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
toast.success(t("messages.loginSuccess"));
|
||||
} catch {
|
||||
toast.error(t("errors.failedUserInfo"));
|
||||
}
|
||||
if (!meRes) throw new Error("Failed to get user info");
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
toast.success(t("messages.loginSuccess"));
|
||||
} catch (_err) {
|
||||
toast.error(t("errors.failedUserInfo"));
|
||||
}
|
||||
}, [
|
||||
onAuthSuccess,
|
||||
setLoggedIn,
|
||||
setIsAdmin,
|
||||
setUsername,
|
||||
setUserId,
|
||||
t,
|
||||
setInternalLoggedIn,
|
||||
]);
|
||||
},
|
||||
[
|
||||
onAuthSuccess,
|
||||
setLoggedIn,
|
||||
setIsAdmin,
|
||||
setUsername,
|
||||
setUserId,
|
||||
t,
|
||||
setInternalLoggedIn,
|
||||
currentServerUrl,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalLoggedIn(loggedIn);
|
||||
@@ -332,18 +327,16 @@ export function Auth({
|
||||
throw new Error(t("errors.loginFailed"));
|
||||
}
|
||||
|
||||
if (isInElectronWebView() && res.token) {
|
||||
if (isInElectronWebView()) {
|
||||
try {
|
||||
localStorage.setItem("jwt", res.token);
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
token: res.token,
|
||||
source: "auth_component",
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
window.location.origin,
|
||||
);
|
||||
setWebviewAuthSuccess(true);
|
||||
return;
|
||||
@@ -537,22 +530,16 @@ export function Auth({
|
||||
throw new Error(t("errors.loginFailed"));
|
||||
}
|
||||
|
||||
if (isElectron() && res.token) {
|
||||
localStorage.setItem("jwt", res.token);
|
||||
}
|
||||
|
||||
if (isInElectronWebView() && res.token) {
|
||||
if (isInElectronWebView()) {
|
||||
try {
|
||||
localStorage.setItem("jwt", res.token);
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
token: res.token,
|
||||
source: "totp_auth_component",
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
window.location.origin,
|
||||
);
|
||||
setWebviewAuthSuccess(true);
|
||||
setTotpLoading(false);
|
||||
@@ -676,43 +663,32 @@ export function Auth({
|
||||
if (success) {
|
||||
setOidcLoading(true);
|
||||
|
||||
const urlToken = urlParams.get("token");
|
||||
if (urlToken && (isElectron() || isInElectronWebView())) {
|
||||
localStorage.setItem("jwt", urlToken);
|
||||
if (isInElectronWebView()) {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
source: "oidc_callback",
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
window.location.origin,
|
||||
);
|
||||
setWebviewAuthSuccess(true);
|
||||
setOidcLoading(false);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error("Error posting auth success message:", e);
|
||||
}
|
||||
}
|
||||
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
if (isInElectronWebView()) {
|
||||
const token = getCookie("jwt") || localStorage.getItem("jwt");
|
||||
if (token) {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
token: token,
|
||||
source: "oidc_callback",
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
);
|
||||
setWebviewAuthSuccess(true);
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error("Error posting auth success message:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isElectron()) {
|
||||
const token = getCookie("jwt");
|
||||
if (token) {
|
||||
localStorage.setItem("jwt", token);
|
||||
}
|
||||
}
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
|
||||
@@ -2,14 +2,19 @@ import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
|
||||
import { setCookie } from "@/ui/main-axios.ts";
|
||||
|
||||
interface ElectronLoginFormProps {
|
||||
serverUrl: string;
|
||||
onAuthSuccess: () => void;
|
||||
onAuthSuccess: (previousJwt: string | null) => void | Promise<void>;
|
||||
onChangeServer: () => void;
|
||||
}
|
||||
|
||||
const AUTH_MESSAGE_SOURCES = new Set([
|
||||
"auth_component",
|
||||
"totp_auth_component",
|
||||
"oidc_callback",
|
||||
]);
|
||||
|
||||
export function ElectronLoginForm({
|
||||
serverUrl,
|
||||
onAuthSuccess,
|
||||
@@ -22,72 +27,69 @@ export function ElectronLoginForm({
|
||||
const isAuthenticatingRef = useRef(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const hasAuthenticatedRef = useRef(false);
|
||||
const [cookieSnapshotReady, setCookieSnapshotReady] = useState(false);
|
||||
const [currentUrl, setCurrentUrl] = useState(serverUrl);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const onAuthSuccessRef = useRef(onAuthSuccess);
|
||||
const initialJwtRef = useRef<string | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
onAuthSuccessRef.current = onAuthSuccess;
|
||||
}, [onAuthSuccess]);
|
||||
|
||||
const handleAuthToken = useCallback(
|
||||
async (token: string) => {
|
||||
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
|
||||
hasAuthenticatedRef.current = true;
|
||||
isAuthenticatingRef.current = true;
|
||||
setIsAuthenticating(true);
|
||||
useEffect(() => {
|
||||
window.electronAPI
|
||||
?.getSessionCookie?.("jwt", serverUrl)
|
||||
.then((value) => {
|
||||
initialJwtRef.current = value;
|
||||
})
|
||||
.catch(() => {
|
||||
initialJwtRef.current = null;
|
||||
})
|
||||
.finally(() => {
|
||||
setCookieSnapshotReady(true);
|
||||
});
|
||||
}, [serverUrl]);
|
||||
|
||||
try {
|
||||
setCookie("jwt", token);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
onAuthSuccessRef.current();
|
||||
} catch (_err) {
|
||||
setError(t("errors.authTokenSaveFailed"));
|
||||
isAuthenticatingRef.current = false;
|
||||
setIsAuthenticating(false);
|
||||
hasAuthenticatedRef.current = false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
const handleAuthSuccess = useCallback(async () => {
|
||||
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
|
||||
hasAuthenticatedRef.current = true;
|
||||
isAuthenticatingRef.current = true;
|
||||
setIsAuthenticating(true);
|
||||
|
||||
// postMessage from server's Auth.tsx (works if server has postMessage code)
|
||||
try {
|
||||
await onAuthSuccessRef.current(initialJwtRef.current ?? null);
|
||||
} catch (_err) {
|
||||
setError(t("errors.authTokenSaveFailed"));
|
||||
isAuthenticatingRef.current = false;
|
||||
setIsAuthenticating(false);
|
||||
hasAuthenticatedRef.current = false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
|
||||
useEffect(() => {
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
try {
|
||||
const expectedOrigin = new URL(serverUrl).origin;
|
||||
if (event.origin !== expectedOrigin) return;
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
if (!event.data || typeof event.data !== "object") return;
|
||||
const { type, token, platform } = event.data;
|
||||
if (type === "AUTH_SUCCESS" && token && platform === "desktop") {
|
||||
await handleAuthToken(token);
|
||||
const { type, platform, source } = event.data;
|
||||
if (
|
||||
type === "AUTH_SUCCESS" &&
|
||||
platform === "desktop" &&
|
||||
AUTH_MESSAGE_SOURCES.has(source)
|
||||
) {
|
||||
await handleAuthSuccess();
|
||||
}
|
||||
} catch (_err) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [handleAuthToken]);
|
||||
|
||||
// Poll iframe localStorage via main process executeJavaScriptInFrame (bypasses cross-origin)
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const electronAPI = (window as any).electronAPI;
|
||||
if (!electronAPI?.invoke) return;
|
||||
|
||||
const poll = setInterval(async () => {
|
||||
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
|
||||
try {
|
||||
const token = await electronAPI.invoke("get-iframe-jwt");
|
||||
if (token && token.length > 20) {
|
||||
await handleAuthToken(token);
|
||||
}
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(poll);
|
||||
}, [loading, handleAuthToken]);
|
||||
}, [handleAuthSuccess, serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
@@ -102,7 +104,7 @@ export function ElectronLoginForm({
|
||||
if (iframe.contentWindow) {
|
||||
setCurrentUrl(iframe.contentWindow.location.href);
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
setCurrentUrl(serverUrl);
|
||||
}
|
||||
};
|
||||
@@ -200,11 +202,11 @@ export function ElectronLoginForm({
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={serverUrl}
|
||||
src={cookieSnapshotReady ? serverUrl : "about:blank"}
|
||||
className="w-full h-full border-0"
|
||||
title="Server Authentication"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation allow-top-navigation allow-top-navigation-by-user-activation allow-modals allow-downloads"
|
||||
allow="clipboard-read; clipboard-write; cross-origin-isolated; camera; microphone; geolocation"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import React, { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
|
||||
import { ServerStats as ServerView } from "@/ui/desktop/apps/features/server-stats/ServerStats.tsx";
|
||||
import { FileManager } from "@/ui/desktop/apps/features/file-manager/FileManager.tsx";
|
||||
import {
|
||||
GuacamoleDisplay,
|
||||
type GuacamoleConnectionConfig,
|
||||
} from "@/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import { TunnelManager } from "@/ui/desktop/apps/features/tunnel/TunnelManager.tsx";
|
||||
import { DockerManager } from "@/ui/desktop/apps/features/docker/DockerManager.tsx";
|
||||
import { NetworkGraphCard } from "@/ui/desktop/apps/dashboard/cards/NetworkGraphCard";
|
||||
import React, {
|
||||
Suspense,
|
||||
lazy,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import type { GuacamoleConnectionConfig } from "@/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable.tsx";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -25,7 +23,56 @@ import {
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
} from "@/constants/terminal-themes";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/dialogs/SSHAuthDialog.tsx";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Terminal = lazy(() =>
|
||||
import("@/ui/desktop/apps/features/terminal/Terminal.tsx").then((module) => ({
|
||||
default: module.Terminal,
|
||||
})),
|
||||
);
|
||||
const ServerView = lazy(() =>
|
||||
import("@/ui/desktop/apps/features/server-stats/ServerStats.tsx").then(
|
||||
(module) => ({
|
||||
default: module.ServerStats,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const FileManager = lazy(() =>
|
||||
import("@/ui/desktop/apps/features/file-manager/FileManager.tsx").then(
|
||||
(module) => ({
|
||||
default: module.FileManager,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const GuacamoleDisplay = lazy(() =>
|
||||
import("@/ui/desktop/apps/features/guacamole/GuacamoleDisplay.tsx").then(
|
||||
(module) => ({
|
||||
default: module.GuacamoleDisplay,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const TunnelManager = lazy(() =>
|
||||
import("@/ui/desktop/apps/features/tunnel/TunnelManager.tsx").then(
|
||||
(module) => ({
|
||||
default: module.TunnelManager,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const DockerManager = lazy(() =>
|
||||
import("@/ui/desktop/apps/features/docker/DockerManager.tsx").then(
|
||||
(module) => ({
|
||||
default: module.DockerManager,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const NetworkGraphCard = lazy(() =>
|
||||
import("@/ui/desktop/apps/dashboard/cards/NetworkGraphCard").then(
|
||||
(module) => ({
|
||||
default: module.NetworkGraphCard,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
@@ -38,7 +85,7 @@ interface TabData {
|
||||
refresh?: () => void;
|
||||
};
|
||||
};
|
||||
hostConfig?: any;
|
||||
hostConfig?: SSHHost;
|
||||
connectionConfig?: GuacamoleConnectionConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -106,6 +153,7 @@ export function AppView({
|
||||
};
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { t: translate } = useTranslation();
|
||||
|
||||
const isDarkMode = useMemo(() => {
|
||||
if (appTheme === "dark") return true;
|
||||
@@ -140,7 +188,6 @@ export function AppView({
|
||||
const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>(
|
||||
{},
|
||||
);
|
||||
const [ready, setReady] = useState<boolean>(true);
|
||||
const [resetKey, setResetKey] = useState<number>(0);
|
||||
const previousStylesRef = useRef<Record<number, React.CSSProperties>>({});
|
||||
|
||||
@@ -372,7 +419,7 @@ export function AppView({
|
||||
const isTerminal = t.type === "terminal";
|
||||
const terminalConfig = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(t.hostConfig as any)?.terminalConfig,
|
||||
...t.hostConfig?.terminalConfig,
|
||||
};
|
||||
|
||||
let themeColors;
|
||||
@@ -402,94 +449,111 @@ export function AppView({
|
||||
: "var(--bg-base)",
|
||||
}}
|
||||
>
|
||||
{t.type === "terminal" ? (
|
||||
<Terminal
|
||||
key={`term-${t.id}-${t.instanceId || ""}`}
|
||||
ref={t.terminalRef}
|
||||
hostConfig={t.hostConfig}
|
||||
isVisible={effectiveVisible}
|
||||
title={t.title}
|
||||
showTitle={false}
|
||||
splitScreen={allSplitScreenTab.length > 0}
|
||||
onClose={() => removeTab(t.id)}
|
||||
onTitleChange={(title) => updateTab(t.id, { title })}
|
||||
onOpenFileManager={
|
||||
(t.hostConfig as any)?.enableFileManager
|
||||
? () =>
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: t.title,
|
||||
hostConfig: t.hostConfig,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
previewTheme={
|
||||
t.id === currentTab ? previewTerminalTheme : null
|
||||
}
|
||||
/>
|
||||
) : t.type === "server_stats" ? (
|
||||
<ServerView
|
||||
key={`stats-${t.id}-${t.instanceId || ""}`}
|
||||
hostConfig={t.hostConfig}
|
||||
title={t.title}
|
||||
isVisible={effectiveVisible}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
embedded
|
||||
/>
|
||||
) : t.type === "rdp" ||
|
||||
t.type === "vnc" ||
|
||||
t.type === "telnet" ? (
|
||||
t.connectionConfig ? (
|
||||
<GuacamoleDisplay
|
||||
key={`guac-${t.id}-${t.instanceId || ""}`}
|
||||
connectionConfig={t.connectionConfig}
|
||||
<Suspense
|
||||
fallback={
|
||||
<SimpleLoader
|
||||
visible={true}
|
||||
message={translate("common.loading")}
|
||||
backgroundColor={
|
||||
isTerminal ? backgroundColor : "var(--bg-base)"
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t.type === "terminal" ? (
|
||||
<Terminal
|
||||
key={`term-${t.id}-${t.instanceId || ""}`}
|
||||
ref={t.terminalRef}
|
||||
hostConfig={t.hostConfig}
|
||||
isVisible={effectiveVisible}
|
||||
onDisconnect={() => removeTab(t.id)}
|
||||
onError={(err) => {
|
||||
toast.error(err);
|
||||
removeTab(t.id);
|
||||
}}
|
||||
title={t.title}
|
||||
showTitle={false}
|
||||
splitScreen={allSplitScreenTab.length > 0}
|
||||
onClose={() => removeTab(t.id)}
|
||||
onTitleChange={(title) => updateTab(t.id, { title })}
|
||||
onOpenFileManager={
|
||||
(t.hostConfig as any)?.enableFileManager
|
||||
? (path?: string) =>
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: t.title,
|
||||
hostConfig: path
|
||||
? {
|
||||
...t.hostConfig!,
|
||||
defaultPath: path,
|
||||
}
|
||||
: t.hostConfig,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
previewTheme={
|
||||
t.id === currentTab ? previewTerminalTheme : null
|
||||
}
|
||||
/>
|
||||
) : t.type === "server_stats" ? (
|
||||
<ServerView
|
||||
key={`stats-${t.id}-${t.instanceId || ""}`}
|
||||
hostConfig={t.hostConfig}
|
||||
title={t.title}
|
||||
isVisible={effectiveVisible}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
embedded
|
||||
/>
|
||||
) : t.type === "rdp" ||
|
||||
t.type === "vnc" ||
|
||||
t.type === "telnet" ? (
|
||||
t.connectionConfig ? (
|
||||
<GuacamoleDisplay
|
||||
key={`guac-${t.id}-${t.instanceId || ""}`}
|
||||
connectionConfig={t.connectionConfig}
|
||||
isVisible={effectiveVisible}
|
||||
onDisconnect={() => removeTab(t.id)}
|
||||
onError={(err) => {
|
||||
toast.error(err);
|
||||
removeTab(t.id);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-red-500">
|
||||
Missing connection configuration
|
||||
</div>
|
||||
)
|
||||
) : t.type === "network_graph" ? (
|
||||
<NetworkGraphCard
|
||||
key={`netgraph-${t.id}-${t.instanceId || ""}`}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
embedded={false}
|
||||
/>
|
||||
) : t.type === "tunnel" ? (
|
||||
<TunnelManager
|
||||
key={`tunnel-${t.id}-${t.instanceId || ""}`}
|
||||
hostConfig={t.hostConfig}
|
||||
title={t.title}
|
||||
isVisible={effectiveVisible}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
embedded
|
||||
/>
|
||||
) : t.type === "docker" ? (
|
||||
<DockerManager
|
||||
key={`docker-${t.id}-${t.instanceId || ""}`}
|
||||
hostConfig={t.hostConfig}
|
||||
title={t.title}
|
||||
isVisible={effectiveVisible}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
embedded
|
||||
onClose={() => removeTab(t.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-red-500">
|
||||
Missing connection configuration
|
||||
</div>
|
||||
)
|
||||
) : t.type === "network_graph" ? (
|
||||
<NetworkGraphCard
|
||||
key={`netgraph-${t.id}-${t.instanceId || ""}`}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
rightSidebarOpen={rightSidebarOpen}
|
||||
rightSidebarWidth={rightSidebarWidth}
|
||||
embedded={false}
|
||||
/>
|
||||
) : t.type === "tunnel" ? (
|
||||
<TunnelManager
|
||||
key={`tunnel-${t.id}-${t.instanceId || ""}`}
|
||||
hostConfig={t.hostConfig}
|
||||
title={t.title}
|
||||
isVisible={effectiveVisible}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
embedded
|
||||
/>
|
||||
) : t.type === "docker" ? (
|
||||
<DockerManager
|
||||
key={`docker-${t.id}-${t.instanceId || ""}`}
|
||||
hostConfig={t.hostConfig}
|
||||
title={t.title}
|
||||
isVisible={effectiveVisible}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
embedded
|
||||
onClose={() => removeTab(t.id)}
|
||||
/>
|
||||
) : (
|
||||
<FileManager
|
||||
key={`filemgr-${t.id}-${t.instanceId || ""}`}
|
||||
embedded
|
||||
initialHost={t.hostConfig}
|
||||
onClose={() => removeTab(t.id)}
|
||||
/>
|
||||
)}
|
||||
<FileManager
|
||||
key={`filemgr-${t.id}-${t.instanceId || ""}`}
|
||||
embedded
|
||||
initialHost={t.hostConfig}
|
||||
onClose={() => removeTab(t.id)}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -577,7 +641,7 @@ export function AppView({
|
||||
const groupId = isRoot ? `main-${node.direction}` : `group-${path}`;
|
||||
|
||||
const groupContent = (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
<ResizablePanelGroup
|
||||
key={groupKey}
|
||||
direction={node.direction}
|
||||
className="h-full w-full"
|
||||
@@ -602,7 +666,7 @@ export function AppView({
|
||||
panel,
|
||||
];
|
||||
})}
|
||||
</ResizablePrimitive.PanelGroup>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
|
||||
if (isRoot) return groupContent;
|
||||
@@ -637,7 +701,7 @@ export function AppView({
|
||||
|
||||
const terminalConfig = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(currentTabData?.hostConfig as any)?.terminalConfig,
|
||||
...currentTabData?.hostConfig?.terminalConfig,
|
||||
};
|
||||
let containerThemeColors;
|
||||
if (terminalConfig.theme === "termix") {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isElectron, logoutUser } from "@/ui/main-axios.ts";
|
||||
import { logoutUser } from "@/ui/main-axios.ts";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -50,10 +50,6 @@ async function handleLogout() {
|
||||
try {
|
||||
await logoutUser();
|
||||
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
@@ -80,13 +76,11 @@ export function LeftSidebar({
|
||||
addTab,
|
||||
setCurrentTab,
|
||||
allSplitScreenTab,
|
||||
updateHostConfig,
|
||||
} = useTabs() as {
|
||||
tabs: Array<{ id: number; type: string; [key: string]: unknown }>;
|
||||
addTab: (tab: { type: string; [key: string]: unknown }) => number;
|
||||
setCurrentTab: (id: number) => void;
|
||||
allSplitScreenTab: number[];
|
||||
updateHostConfig: (id: number, config: unknown) => void;
|
||||
};
|
||||
const isSplitScreenActive =
|
||||
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
||||
|
||||
@@ -10,47 +10,20 @@ import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
|
||||
import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx";
|
||||
import { useCommandHistory } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { QuickConnectDialog } from "@/ui/desktop/navigation/dialogs/QuickConnectDialog.tsx";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu.tsx";
|
||||
import {
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Palette,
|
||||
Terminal as TerminalIcon,
|
||||
} from "lucide-react";
|
||||
import { TERMINAL_THEMES } from "@/constants/terminal-themes.ts";
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
terminalRef?: {
|
||||
current?: {
|
||||
sendInput?: (data: string) => void;
|
||||
};
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
import type { TabContextTab } from "@/types";
|
||||
|
||||
type TabData = TabContextTab;
|
||||
|
||||
interface TopNavbarProps {
|
||||
isTopbarOpen: boolean;
|
||||
setIsTopbarOpen: (open: boolean) => void;
|
||||
onOpenCommandPalette: () => void;
|
||||
onRightSidebarStateChange?: (isOpen: boolean, width: number) => void;
|
||||
}
|
||||
|
||||
export function TopNavbar({
|
||||
isTopbarOpen,
|
||||
setIsTopbarOpen,
|
||||
onOpenCommandPalette,
|
||||
onRightSidebarStateChange,
|
||||
}: TopNavbarProps): React.ReactElement {
|
||||
const { state } = useSidebar();
|
||||
@@ -58,14 +31,12 @@ export function TopNavbar({
|
||||
tabs,
|
||||
currentTab,
|
||||
setCurrentTab,
|
||||
setSplitScreenTab,
|
||||
removeTab,
|
||||
allSplitScreenTab,
|
||||
reorderTabs,
|
||||
updateTab,
|
||||
previewTerminalTheme,
|
||||
setPreviewTerminalTheme,
|
||||
} = useTabs() as any;
|
||||
} = useTabs();
|
||||
const leftPosition =
|
||||
state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)";
|
||||
const { t } = useTranslation();
|
||||
@@ -145,7 +116,7 @@ export function TopNavbar({
|
||||
setCurrentTab(tabId);
|
||||
};
|
||||
|
||||
const handleTabSplit = (tabId: number) => {
|
||||
const handleTabSplit = () => {
|
||||
setToolsSidebarOpen(true);
|
||||
setCommandHistoryTabActive(false);
|
||||
setSplitScreenTabActive(true);
|
||||
@@ -353,12 +324,6 @@ export function TopNavbar({
|
||||
|
||||
const isSplitScreenActive =
|
||||
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
||||
const currentTabObj = tabs.find((t: TabData) => t.id === currentTab);
|
||||
const currentTabIsHome = currentTabObj?.type === "home";
|
||||
const currentTabIsSshManager = currentTabObj?.type === "ssh_manager";
|
||||
const currentTabIsAdmin = currentTabObj?.type === "admin";
|
||||
const currentTabIsUserProfile = currentTabObj?.type === "user_profile";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -406,7 +371,6 @@ export function TopNavbar({
|
||||
const disableClose = isHome;
|
||||
|
||||
const isDraggingThisTab = dragState.draggedIndex === index;
|
||||
const isTheDraggedTab = tab.id === dragState.draggedId;
|
||||
const isDroppedAndSnapping = tab.id === justDroppedTabId;
|
||||
const dragOffset = isDraggingThisTab
|
||||
? dragState.currentX - dragState.startX
|
||||
@@ -516,9 +480,7 @@ export function TopNavbar({
|
||||
? () => handleTabClose(tab.id)
|
||||
: undefined
|
||||
}
|
||||
onSplit={
|
||||
isSplittable ? () => handleTabSplit(tab.id) : undefined
|
||||
}
|
||||
onSplit={isSplittable ? handleTabSplit : undefined}
|
||||
canSplit={isSplittable}
|
||||
canClose={
|
||||
isTerminal ||
|
||||
@@ -540,6 +502,11 @@ export function TopNavbar({
|
||||
isDragging={isDraggingThisTab}
|
||||
isDragOver={false}
|
||||
hostConfig={tab.hostConfig}
|
||||
onOpenFileManager={
|
||||
isTerminal && (tab.hostConfig as any)?.enableFileManager
|
||||
? () => tab.terminalRef?.current?.openFileManager?.()
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -549,73 +516,6 @@ export function TopNavbar({
|
||||
<div className="flex items-center justify-center gap-2 flex-1 px-2">
|
||||
<TabDropdown />
|
||||
|
||||
{/* Terminal Theme Switcher */}
|
||||
{(() => {
|
||||
const activeTab = tabs.find((t: any) => t.id === currentTab);
|
||||
if (activeTab?.type !== "terminal") return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[30px] h-[30px] border-edge"
|
||||
title={t("hosts.selectTheme")}
|
||||
>
|
||||
<TerminalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-canvas border-edge text-foreground max-h-[400px] overflow-y-auto thin-scrollbar"
|
||||
onMouseLeave={() => setPreviewTerminalTheme(null)}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs opacity-70">
|
||||
Terminal Themes
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{Object.entries(TERMINAL_THEMES).map(([key, theme]) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => {
|
||||
const activeTab = tabs.find(
|
||||
(t: any) => t.id === currentTab,
|
||||
);
|
||||
if (activeTab?.hostConfig) {
|
||||
const updatedConfig = {
|
||||
...activeTab.hostConfig.terminalConfig,
|
||||
theme: key,
|
||||
};
|
||||
|
||||
// Persist terminal theme selection to localStorage
|
||||
localStorage.setItem(
|
||||
`terminal_theme_host_${activeTab.hostConfig.id}`,
|
||||
key,
|
||||
);
|
||||
|
||||
updateTab(currentTab, {
|
||||
hostConfig: {
|
||||
...activeTab.hostConfig,
|
||||
terminalConfig: updatedConfig,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => setPreviewTerminalTheme(key)}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full border border-edge"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
/>
|
||||
<span>{theme.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
})()}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setToolsSidebarOpen(!toolsSidebarOpen)}
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ConnectionLog({
|
||||
try {
|
||||
await navigator.clipboard.writeText(logsText);
|
||||
toast.success(t("terminal.connectionLogCopied"));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("terminal.connectionLogCopyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,8 +35,6 @@ export function HostKeyVerificationDialog({
|
||||
hostname,
|
||||
fingerprint,
|
||||
oldFingerprint,
|
||||
keyType,
|
||||
oldKeyType,
|
||||
algorithm,
|
||||
onAccept,
|
||||
onReject,
|
||||
|
||||
@@ -19,7 +19,6 @@ interface OPKSSHDialogProps {
|
||||
export function OPKSSHDialog({
|
||||
isOpen,
|
||||
authUrl,
|
||||
requestId,
|
||||
stage,
|
||||
error,
|
||||
providers,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PassphraseDialogProps {
|
||||
isOpen: boolean;
|
||||
onSubmit: (passphrase: string) => void;
|
||||
onCancel: () => void;
|
||||
hostInfo: { ip: string; port: number; username: string; name?: string };
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function PassphraseDialog({
|
||||
isOpen,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
hostInfo,
|
||||
backgroundColor,
|
||||
}: PassphraseDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hostDisplay = hostInfo.name
|
||||
? `${hostInfo.name} (${hostInfo.username}@${hostInfo.ip}:${hostInfo.port})`
|
||||
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("auth.passphraseRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{hostDisplay}</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.elements.namedItem(
|
||||
"passphrase",
|
||||
) as HTMLInputElement;
|
||||
if (input && input.value) {
|
||||
onSubmit(input.value);
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="passphrase">
|
||||
{t("auth.passphraseRequiredDescription")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="passphrase"
|
||||
name="passphrase"
|
||||
autoFocus
|
||||
placeholder={t("placeholders.keyPassword")}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="flex-1">
|
||||
{t("common.connect")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useForm, Controller, type Resolver } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { quickConnect, getCredentials } from "@/ui/main-axios.ts";
|
||||
import { quickConnect } from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
@@ -38,7 +38,7 @@ import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { githubLight } from "@uiw/codemirror-theme-github";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { useTheme } from "@/components/theme-provider.tsx";
|
||||
import type { SSHHost, Credential } from "@/types";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
interface QuickConnectDialogProps {
|
||||
open: boolean;
|
||||
@@ -70,7 +70,6 @@ export function QuickConnectDialog({
|
||||
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
|
||||
"upload",
|
||||
);
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
|
||||
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
@@ -151,7 +150,7 @@ export function QuickConnectDialog({
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
resolver: zodResolver(formSchema) as unknown as Resolver<FormData>,
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
ip: "",
|
||||
@@ -167,21 +166,6 @@ export function QuickConnectDialog({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCredentials = async () => {
|
||||
try {
|
||||
const data = await getCredentials();
|
||||
setCredentials((data as Credential[]) || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch credentials:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (open) {
|
||||
fetchCredentials();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue("authType", authTab, { shouldValidate: true });
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
|
||||
@@ -15,7 +15,6 @@ interface TOTPDialogProps {
|
||||
|
||||
export function TOTPDialog({
|
||||
isOpen,
|
||||
prompt,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
backgroundColor,
|
||||
|
||||
@@ -36,7 +36,7 @@ export function WarpgateDialog({
|
||||
setCopied(true);
|
||||
toast.success(t("common.copied"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("common.copyFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getGuacamoleToken,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
logActivity,
|
||||
@@ -96,7 +95,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
}
|
||||
try {
|
||||
return JSON.parse(host.statsConfig);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
}, [host.statsConfig]);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Terminal as TerminalIcon,
|
||||
Server as ServerIcon,
|
||||
Folder as FolderIcon,
|
||||
FolderOpen,
|
||||
User as UserIcon,
|
||||
Monitor as MonitorIcon,
|
||||
Eye as EyeIcon,
|
||||
@@ -40,6 +41,7 @@ interface TabProps {
|
||||
isValidDropTarget?: boolean;
|
||||
isHoveredDropTarget?: boolean;
|
||||
hostConfig?: SSHHost;
|
||||
onOpenFileManager?: () => void;
|
||||
}
|
||||
|
||||
export function Tab({
|
||||
@@ -60,6 +62,7 @@ export function Tab({
|
||||
isValidDropTarget = false,
|
||||
isHoveredDropTarget = false,
|
||||
hostConfig,
|
||||
onOpenFileManager,
|
||||
}: TabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -75,16 +78,14 @@ export function Tab({
|
||||
|
||||
if (!hasSshPw && !hasSudoPw) return;
|
||||
|
||||
let passwordToCopy = "";
|
||||
const field = hasSshPw ? "password" : "sudoPassword";
|
||||
const passwordToCopy = await getHostPassword(hostConfig.id, field);
|
||||
|
||||
if (hasSshPw) {
|
||||
passwordToCopy = hostConfig.password || "";
|
||||
} else if (hasSudoPw) {
|
||||
passwordToCopy = hostConfig.sudoPassword;
|
||||
if (!passwordToCopy) {
|
||||
toast.error(t("nav.failedToCopyPassword"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordToCopy) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(passwordToCopy);
|
||||
toast.success(t("nav.passwordCopied"));
|
||||
@@ -272,6 +273,21 @@ export function Tab({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{tabType === "terminal" && onOpenFileManager && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenFileManager();
|
||||
}}
|
||||
title={t("nav.openFileManager")}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canSplit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -9,7 +9,7 @@ import React, {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TabContextTab } from "../../../types/index.js";
|
||||
import type { TabContextTab, TerminalRefHandle } from "../../../types/index.js";
|
||||
|
||||
export type Tab = TabContextTab;
|
||||
|
||||
@@ -40,6 +40,10 @@ interface TabContextType {
|
||||
|
||||
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||
|
||||
type ElectronWindow = Window & {
|
||||
electronAPI?: unknown;
|
||||
};
|
||||
|
||||
export function useTabs() {
|
||||
const context = useContext(TabContext);
|
||||
if (context === undefined) {
|
||||
@@ -70,7 +74,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
const [tabs, setTabs] = useState<Tab[]>(() => {
|
||||
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
||||
const isElectron =
|
||||
typeof window !== "undefined" && !!(window as any).electronAPI;
|
||||
typeof window !== "undefined" && !!(window as ElectronWindow).electronAPI;
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") === "true";
|
||||
const shouldRestore = isMobile || isElectron || persistenceEnabled;
|
||||
@@ -92,7 +96,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
instanceId: tab.instanceId,
|
||||
terminalRef:
|
||||
tab.type === "terminal"
|
||||
? React.createRef<{ disconnect?: () => void }>()
|
||||
? React.createRef<TerminalRefHandle>()
|
||||
: undefined,
|
||||
hostConfig: tab.hostConfig
|
||||
? {
|
||||
@@ -139,7 +143,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
useEffect(() => {
|
||||
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
|
||||
const isElectron =
|
||||
typeof window !== "undefined" && !!(window as any).electronAPI;
|
||||
typeof window !== "undefined" && !!(window as ElectronWindow).electronAPI;
|
||||
const persistenceEnabled =
|
||||
localStorage.getItem("enableTerminalSessionPersistence") === "true";
|
||||
const shouldSave = isMobile || isElectron || persistenceEnabled;
|
||||
@@ -147,7 +151,11 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
if (shouldSave) {
|
||||
const serializable = tabs
|
||||
.filter((t) => t.type !== "home")
|
||||
.map(({ terminalRef, ...rest }) => rest);
|
||||
.map((tab) => {
|
||||
const rest = { ...tab };
|
||||
delete rest.terminalRef;
|
||||
return rest;
|
||||
});
|
||||
localStorage.setItem("termix_tabs", JSON.stringify(serializable));
|
||||
localStorage.setItem("termix_currentTab", String(currentTab));
|
||||
} else {
|
||||
@@ -261,7 +269,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
title: effectiveTitle,
|
||||
terminalRef:
|
||||
tabData.type === "terminal"
|
||||
? React.createRef<{ disconnect?: () => void }>()
|
||||
? React.createRef<TerminalRefHandle>()
|
||||
: undefined,
|
||||
hostConfig: tabData.hostConfig
|
||||
? {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,18 +2,21 @@ import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { VersionAlert } from "@/components/ui/version-alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { checkElectronUpdate, isElectron } from "@/ui/main-axios.ts";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { checkElectronUpdate } from "@/ui/main-axios.ts";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
|
||||
interface VersionCheckModalProps {
|
||||
onContinue: () => void;
|
||||
isAuthenticated?: boolean;
|
||||
}
|
||||
|
||||
export function ElectronVersionCheck({
|
||||
onContinue,
|
||||
isAuthenticated = false,
|
||||
}: VersionCheckModalProps) {
|
||||
type ElectronWindow = Window & {
|
||||
electronAPI?: {
|
||||
getAppVersion?: () => Promise<string | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
export function ElectronVersionCheck({ onContinue }: VersionCheckModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
const [versionInfo, setVersionInfo] = useState<Record<
|
||||
@@ -32,6 +35,10 @@ export function ElectronVersionCheck({
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
|
||||
const versionModalTitle =
|
||||
versionInfo?.status === "beta"
|
||||
? t("versionCheck.betaVersion")
|
||||
: t("versionCheck.updateRequired");
|
||||
|
||||
useEffect(() => {
|
||||
const updateCheckDisabled =
|
||||
@@ -53,7 +60,9 @@ export function ElectronVersionCheck({
|
||||
const updateInfo = await checkElectronUpdate();
|
||||
setVersionInfo(updateInfo);
|
||||
|
||||
const currentVersion = await (window as any).electronAPI?.getAppVersion();
|
||||
const currentVersion = await (
|
||||
window as ElectronWindow
|
||||
).electronAPI?.getAppVersion?.();
|
||||
const dismissedVersion = localStorage.getItem(
|
||||
"electron-version-check-dismissed",
|
||||
);
|
||||
@@ -88,7 +97,9 @@ export function ElectronVersionCheck({
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
const currentVersion = await (window as any).electronAPI?.getAppVersion();
|
||||
const currentVersion = await (
|
||||
window as ElectronWindow
|
||||
).electronAPI?.getAppVersion?.();
|
||||
if (currentVersion) {
|
||||
localStorage.setItem("electron-version-check-dismissed", currentVersion);
|
||||
}
|
||||
@@ -183,9 +194,7 @@ export function ElectronVersionCheck({
|
||||
>
|
||||
<div className="w-[420px] max-w-full p-8 flex flex-col backdrop-blur-sm bg-card/50 rounded-2xl shadow-xl border-2 border-edge overflow-y-auto thin-scrollbar my-2 animate-in fade-in zoom-in-95 duration-300">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("versionCheck.updateRequired")}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold">{versionModalTitle}</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
|
||||
@@ -15,16 +15,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PasswordResetProps {
|
||||
userInfo: {
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
totp_enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function PasswordReset({ userInfo }: PasswordResetProps) {
|
||||
export function PasswordReset() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
|
||||
+451
-433
@@ -26,6 +26,7 @@ import {
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Network,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx";
|
||||
@@ -43,11 +44,14 @@ import { useTranslation } from "react-i18next";
|
||||
import { LanguageSwitcher } from "@/ui/desktop/user/LanguageSwitcher.tsx";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { C2STunnelPresetManager } from "@/ui/desktop/user/C2STunnelPresetManager.tsx";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface UserProfileProps {
|
||||
isTopbarOpen?: boolean;
|
||||
rightSidebarOpen?: boolean;
|
||||
rightSidebarWidth?: number;
|
||||
initialTab?: string;
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
@@ -55,8 +59,6 @@ async function handleLogout() {
|
||||
await logoutUser();
|
||||
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
const configuredServerUrl = (
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
@@ -94,6 +96,7 @@ export function UserProfile({
|
||||
isTopbarOpen = true,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
initialTab = "profile",
|
||||
}: UserProfileProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
@@ -157,6 +160,16 @@ export function UserProfile({
|
||||
return saved === "true";
|
||||
});
|
||||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
const supportsClientTunnels = isElectron();
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(
|
||||
initialTab === "c2s-tunnels" && !supportsClientTunnels
|
||||
? "profile"
|
||||
: initialTab,
|
||||
);
|
||||
}, [initialTab, supportsClientTunnels]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserInfo();
|
||||
@@ -302,28 +315,7 @@ export function UserProfile({
|
||||
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden"
|
||||
>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t("nav.userProfile")}</h1>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="animate-pulse text-foreground-secondary">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !userInfo) {
|
||||
if (!loading && (error || !userInfo)) {
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
@@ -359,6 +351,7 @@ export function UserProfile({
|
||||
style={wrapperStyle}
|
||||
className="bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden"
|
||||
>
|
||||
<SimpleLoader visible={loading} message={t("common.loading")} />
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t("nav.userProfile")}</h1>
|
||||
@@ -366,452 +359,477 @@ export function UserProfile({
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="px-6 py-4 overflow-auto thin-scrollbar flex-1">
|
||||
<Tabs defaultValue="profile" className="w-full">
|
||||
<TabsList className="mb-4 bg-elevated border-2 border-edge">
|
||||
<TabsTrigger
|
||||
value="profile"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
{t("profile.account")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="appearance"
|
||||
className="flex items-center gap-2 data-[state=active]:bg-button"
|
||||
>
|
||||
<Palette className="w-4 h-4" />
|
||||
{t("profile.appearance")}
|
||||
</TabsTrigger>
|
||||
{(!userInfo.is_oidc || userInfo.is_dual_auth) && (
|
||||
{userInfo && (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="mb-4 bg-elevated border-2 border-edge">
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
value="profile"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
{t("profile.security")}
|
||||
<User className="w-4 h-4" />
|
||||
{t("profile.account")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.accountInfo")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-foreground">
|
||||
{userInfo.username}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.role")}
|
||||
</Label>
|
||||
<div className="mt-1">
|
||||
{userRoles.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{userRoles.map((role) => (
|
||||
<span
|
||||
key={role.roleId}
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium bg-muted/50 text-foreground border border-border"
|
||||
>
|
||||
{t(role.roleDisplayName)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-lg font-medium text-foreground">
|
||||
{userInfo.is_admin
|
||||
? t("interface.administrator")
|
||||
: t("interface.user")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.authMethod")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-foreground">
|
||||
{userInfo.is_dual_auth
|
||||
? t("profile.externalAndLocal")
|
||||
: userInfo.is_oidc
|
||||
? t("profile.external")
|
||||
: t("profile.local")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.twoFactorAuth")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_oidc && !userInfo.is_dual_auth ? (
|
||||
<span className="text-muted-foreground">
|
||||
{t("auth.lockedOidcAuth")}
|
||||
</span>
|
||||
) : userInfo.totp_enabled ? (
|
||||
<span className="text-green-400 flex items-center gap-1">
|
||||
<Shield className="w-4 h-4" />
|
||||
{t("common.enabled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t("common.disabled")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("common.version")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-foreground">
|
||||
{versionInfo?.version || t("common.loading")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-edge">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-red-400">
|
||||
{t("leftSidebar.deleteAccount")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("leftSidebar.deleteAccountWarningShort")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteAccountOpen(true)}
|
||||
>
|
||||
{t("leftSidebar.deleteAccount")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="appearance" className="space-y-4">
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.languageLocalization")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("common.language")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.selectPreferredLanguage")}
|
||||
</p>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
<TabsTrigger
|
||||
value="appearance"
|
||||
className="flex items-center gap-2 data-[state=active]:bg-button"
|
||||
>
|
||||
<Palette className="w-4 h-4" />
|
||||
{t("profile.appearance")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
</TabsTrigger>
|
||||
{supportsClientTunnels && (
|
||||
<TabsTrigger
|
||||
value="c2s-tunnels"
|
||||
className="flex items-center gap-2 data-[state=active]:bg-button"
|
||||
>
|
||||
<Network className="w-4 h-4" />
|
||||
{t("tunnels.clientTunnels")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{(!userInfo.is_oidc || userInfo.is_dual_auth) && (
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
{t("profile.security")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.accountInfo")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.theme")}
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.appearanceDesc")}
|
||||
<p className="text-lg font-medium mt-1 text-foreground">
|
||||
{userInfo.username}
|
||||
</p>
|
||||
</div>
|
||||
<Select value={theme} onValueChange={setTheme}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
onMouseLeave={() => setThemePreview(null)}
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.role")}
|
||||
</Label>
|
||||
<div className="mt-1">
|
||||
{userRoles.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{userRoles.map((role) => (
|
||||
<span
|
||||
key={role.roleId}
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium bg-muted/50 text-foreground border border-border"
|
||||
>
|
||||
{t(role.roleDisplayName)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-lg font-medium text-foreground">
|
||||
{userInfo.is_admin
|
||||
? t("interface.administrator")
|
||||
: t("interface.user")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.authMethod")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-foreground">
|
||||
{userInfo.is_dual_auth
|
||||
? t("profile.externalAndLocal")
|
||||
: userInfo.is_oidc
|
||||
? t("profile.external")
|
||||
: t("profile.local")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.twoFactorAuth")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_oidc && !userInfo.is_dual_auth ? (
|
||||
<span className="text-muted-foreground">
|
||||
{t("auth.lockedOidcAuth")}
|
||||
</span>
|
||||
) : userInfo.totp_enabled ? (
|
||||
<span className="text-green-400 flex items-center gap-1">
|
||||
<Shield className="w-4 h-4" />
|
||||
{t("common.enabled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{t("common.disabled")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("common.version")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-foreground">
|
||||
{versionInfo?.version || t("common.loading")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-edge">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-red-400">
|
||||
{t("leftSidebar.deleteAccount")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("leftSidebar.deleteAccountWarningShort")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteAccountOpen(true)}
|
||||
>
|
||||
<SelectItem
|
||||
value="light"
|
||||
onMouseEnter={() => setThemePreview("light")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun className="w-4 h-4" />
|
||||
{t("profile.themeLight")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="dark"
|
||||
onMouseEnter={() => setThemePreview("dark")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Moon className="w-4 h-4" />
|
||||
{t("profile.themeDark")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="dracula"
|
||||
onMouseEnter={() => setThemePreview("dracula")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Dracula
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="gentlemansChoice"
|
||||
onMouseEnter={() =>
|
||||
setThemePreview("gentlemansChoice")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Gentleman's Choice
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="midnightEspresso"
|
||||
onMouseEnter={() =>
|
||||
setThemePreview("midnightEspresso")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Midnight Espresso
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="catppuccinMocha"
|
||||
onMouseEnter={() =>
|
||||
setThemePreview("catppuccinMocha")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Catppuccin Mocha
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="system"
|
||||
onMouseEnter={() => setThemePreview("system")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
{t("profile.themeSystem")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{t("leftSidebar.deleteAccount")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.fileManagerSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.fileColorCoding")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.fileColorCodingDesc")}
|
||||
</p>
|
||||
<TabsContent value="appearance" className="space-y-4">
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.languageLocalization")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("common.language")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.selectPreferredLanguage")}
|
||||
</p>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Switch
|
||||
checked={fileColorCoding}
|
||||
onCheckedChange={handleFileColorCodingToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.terminalSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.commandAutocomplete")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.commandAutocompleteDesc")}
|
||||
</p>
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.appearance")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.theme")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.appearanceDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Select value={theme} onValueChange={setTheme}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
onMouseLeave={() => setThemePreview(null)}
|
||||
>
|
||||
<SelectItem
|
||||
value="light"
|
||||
onMouseEnter={() => setThemePreview("light")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun className="w-4 h-4" />
|
||||
{t("profile.themeLight")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="dark"
|
||||
onMouseEnter={() => setThemePreview("dark")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Moon className="w-4 h-4" />
|
||||
{t("profile.themeDark")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="dracula"
|
||||
onMouseEnter={() => setThemePreview("dracula")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Dracula
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="gentlemansChoice"
|
||||
onMouseEnter={() =>
|
||||
setThemePreview("gentlemansChoice")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Gentleman's Choice
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="midnightEspresso"
|
||||
onMouseEnter={() =>
|
||||
setThemePreview("midnightEspresso")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Midnight Espresso
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="catppuccinMocha"
|
||||
onMouseEnter={() =>
|
||||
setThemePreview("catppuccinMocha")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
Catppuccin Mocha
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="system"
|
||||
onMouseEnter={() => setThemePreview("system")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
{t("profile.themeSystem")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Switch
|
||||
checked={commandAutocomplete}
|
||||
onCheckedChange={handleCommandAutocompleteToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.commandHistoryTracking")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.commandHistoryTrackingDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={commandHistoryTracking}
|
||||
onCheckedChange={handleCommandHistoryTrackingToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.terminalSyntaxHighlighting")}{" "}
|
||||
<span className="text-xs text-yellow-500 font-semibold">
|
||||
(BETA)
|
||||
</span>
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.terminalSyntaxHighlightingDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalSyntaxHighlighting}
|
||||
onCheckedChange={handleTerminalSyntaxHighlightingToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.enableCommandPaletteShortcut")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.enableCommandPaletteShortcutDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={commandPaletteShortcutEnabled}
|
||||
onCheckedChange={handleCommandPaletteShortcutToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.enableTerminalSessionPersistence")}{" "}
|
||||
<span className="text-xs text-yellow-500 font-semibold">
|
||||
(BETA)
|
||||
</span>
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.enableTerminalSessionPersistenceDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enableTerminalSessionPersistence}
|
||||
onCheckedChange={handleTerminalSessionPersistenceToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.hostSidebarSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.showHostTags")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.showHostTagsDesc")}
|
||||
</p>
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.fileManagerSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.fileColorCoding")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.fileColorCodingDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={fileColorCoding}
|
||||
onCheckedChange={handleFileColorCodingToggle}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={showHostTags}
|
||||
onCheckedChange={handleShowHostTagsToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.snippetsSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.defaultSnippetFoldersCollapsed")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.defaultSnippetFoldersCollapsedDesc")}
|
||||
</p>
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.terminalSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.commandAutocomplete")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.commandAutocompleteDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={commandAutocomplete}
|
||||
onCheckedChange={handleCommandAutocompleteToggle}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={defaultSnippetFoldersCollapsed}
|
||||
onCheckedChange={
|
||||
handleDefaultSnippetFoldersCollapsedToggle
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.confirmSnippetExecution")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.confirmSnippetExecutionDesc")}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.commandHistoryTracking")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.commandHistoryTrackingDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={commandHistoryTracking}
|
||||
onCheckedChange={handleCommandHistoryTrackingToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.terminalSyntaxHighlighting")}{" "}
|
||||
<span className="text-xs text-yellow-500 font-semibold">
|
||||
(BETA)
|
||||
</span>
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.terminalSyntaxHighlightingDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={terminalSyntaxHighlighting}
|
||||
onCheckedChange={
|
||||
handleTerminalSyntaxHighlightingToggle
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.enableCommandPaletteShortcut")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.enableCommandPaletteShortcutDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={commandPaletteShortcutEnabled}
|
||||
onCheckedChange={handleCommandPaletteShortcutToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.enableTerminalSessionPersistence")}{" "}
|
||||
<span className="text-xs text-yellow-500 font-semibold">
|
||||
(BETA)
|
||||
</span>
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.enableTerminalSessionPersistenceDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enableTerminalSessionPersistence}
|
||||
onCheckedChange={
|
||||
handleTerminalSessionPersistenceToggle
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={confirmSnippetExecution}
|
||||
onCheckedChange={handleConfirmSnippetExecutionToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.updateSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.disableUpdateCheck")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.disableUpdateCheckDesc")}
|
||||
</p>
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.hostSidebarSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.showHostTags")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.showHostTagsDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={showHostTags}
|
||||
onCheckedChange={handleShowHostTagsToggle}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={disableUpdateCheck}
|
||||
onCheckedChange={handleDisableUpdateCheckToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<TOTPSetup
|
||||
isEnabled={userInfo.totp_enabled}
|
||||
onStatusChange={handleTOTPStatusChange}
|
||||
/>
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.snippetsSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.defaultSnippetFoldersCollapsed")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.defaultSnippetFoldersCollapsedDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={defaultSnippetFoldersCollapsed}
|
||||
onCheckedChange={
|
||||
handleDefaultSnippetFoldersCollapsedToggle
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.confirmSnippetExecution")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.confirmSnippetExecutionDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={confirmSnippetExecution}
|
||||
onCheckedChange={handleConfirmSnippetExecutionToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(!userInfo.is_oidc || userInfo.is_dual_auth) && (
|
||||
<PasswordReset userInfo={userInfo} />
|
||||
<div className="rounded-lg border-2 border-edge bg-elevated p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.updateSettings")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground-secondary">
|
||||
{t("profile.disableUpdateCheck")}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("profile.disableUpdateCheckDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={disableUpdateCheck}
|
||||
onCheckedChange={handleDisableUpdateCheckToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{supportsClientTunnels && (
|
||||
<TabsContent value="c2s-tunnels" className="space-y-4">
|
||||
<C2STunnelPresetManager />
|
||||
</TabsContent>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<TOTPSetup
|
||||
isEnabled={userInfo.totp_enabled}
|
||||
onStatusChange={handleTOTPStatusChange}
|
||||
/>
|
||||
|
||||
{(!userInfo.is_oidc || userInfo.is_dual_auth) && (
|
||||
<PasswordReset userInfo={userInfo} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useCommandHistory({
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const historyCache = useRef<Map<number, string[]>>(new Map());
|
||||
s;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !hostId) {
|
||||
setCommandHistory([]);
|
||||
|
||||
+364
-174
@@ -1,6 +1,7 @@
|
||||
import axios, { AxiosError, type AxiosInstance } from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { clearTermixSessionStorage } from "@/ui/desktop/navigation/tabs/TabContext";
|
||||
import type {
|
||||
SSHHost,
|
||||
@@ -8,6 +9,8 @@ import type {
|
||||
SSHFolder,
|
||||
TunnelConfig,
|
||||
TunnelStatus,
|
||||
TunnelConnection,
|
||||
C2STunnelPreset,
|
||||
FileManagerFile,
|
||||
FileManagerShortcut,
|
||||
DockerContainer,
|
||||
@@ -86,6 +89,27 @@ export type SSHHostWithStatus = SSHHost & {
|
||||
status: "online" | "offline" | "unknown";
|
||||
};
|
||||
|
||||
type ApiConnectionLog = {
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
stage: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ConnectErrorResponse = {
|
||||
error?: string;
|
||||
message?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
requires_totp?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
interface CpuMetrics {
|
||||
percent: number | null;
|
||||
cores: number | null;
|
||||
@@ -113,7 +137,6 @@ export type ServerMetrics = {
|
||||
};
|
||||
|
||||
interface AuthResponse {
|
||||
token: string;
|
||||
success?: boolean;
|
||||
is_admin?: boolean;
|
||||
username?: string;
|
||||
@@ -144,18 +167,24 @@ interface OIDCAuthorize {
|
||||
auth_url: string;
|
||||
}
|
||||
|
||||
type ElectronApi = {
|
||||
isElectron?: boolean;
|
||||
getSetting?: (key: string) => Promise<string | null | undefined>;
|
||||
setSetting?: (key: string, value: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type ElectronWindow = Window &
|
||||
typeof globalThis & {
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: ElectronApi;
|
||||
ReactNativeWebView?: unknown;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// UTILITY FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export function isElectron(): boolean {
|
||||
const win = window as any;
|
||||
const hasISElectron = win.IS_ELECTRON === true;
|
||||
const hasElectronAPI = !!win.electronAPI;
|
||||
const isElectronProp = win.electronAPI?.isElectron === true;
|
||||
|
||||
return hasISElectron || hasElectronAPI || isElectronProp;
|
||||
}
|
||||
export { isElectron };
|
||||
|
||||
function getLoggerForService(serviceName: string) {
|
||||
if (serviceName.includes("SSH") || serviceName.includes("ssh")) {
|
||||
@@ -183,10 +212,10 @@ const electronSettingsCache = new Map<string, string>();
|
||||
if (isElectron()) {
|
||||
(async () => {
|
||||
try {
|
||||
const electronAPI = (window as any).electronAPI;
|
||||
const electronAPI = (window as ElectronWindow).electronAPI;
|
||||
|
||||
if (electronAPI?.getSetting) {
|
||||
const settingsToLoad = ["rightClickCopyPaste", "jwt"];
|
||||
const settingsToLoad = ["rightClickCopyPaste"];
|
||||
for (const key of settingsToLoad) {
|
||||
const value = await electronAPI.getSetting(key);
|
||||
if (value !== null && value !== undefined) {
|
||||
@@ -208,27 +237,28 @@ if (isElectron()) {
|
||||
})();
|
||||
}
|
||||
|
||||
export function setCookie(name: string, value: string, days = 7): void {
|
||||
export function setCookie(
|
||||
name: string,
|
||||
value: string,
|
||||
days = 7,
|
||||
): void | Promise<void> {
|
||||
if (isElectron()) {
|
||||
try {
|
||||
electronSettingsCache.set(name, value);
|
||||
if (name === "jwt") {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(name, value);
|
||||
|
||||
const electronAPI = (
|
||||
window as Window &
|
||||
typeof globalThis & {
|
||||
electronAPI?: any;
|
||||
}
|
||||
).electronAPI;
|
||||
const electronAPI = (window as ElectronWindow).electronAPI;
|
||||
|
||||
if (electronAPI?.setSetting) {
|
||||
electronSettingsCache.set(name, value);
|
||||
localStorage.setItem(name, value);
|
||||
electronAPI.setSetting(name, value).catch((err: Error) => {
|
||||
console.error(`[Electron] Failed to persist setting ${name}:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[Electron] Set setting: ${name} = ${value}`);
|
||||
console.log(`[Electron] Set setting: ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`[Electron] Failed to set setting: ${name}`, error);
|
||||
}
|
||||
@@ -241,6 +271,10 @@ export function setCookie(name: string, value: string, days = 7): void {
|
||||
export function getCookie(name: string): string | undefined {
|
||||
if (isElectron()) {
|
||||
try {
|
||||
if (name === "jwt") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (electronSettingsCache.has(name)) {
|
||||
return electronSettingsCache.get(name);
|
||||
}
|
||||
@@ -266,6 +300,44 @@ export function getCookie(name: string): string | undefined {
|
||||
}
|
||||
|
||||
let userWasAuthenticated = false;
|
||||
let latestAuthSuccessAt = 0;
|
||||
|
||||
function markUserAuthenticated(): void {
|
||||
userWasAuthenticated = true;
|
||||
latestAuthSuccessAt =
|
||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
export function isCurrentAuthInvalidationError(error: unknown): boolean {
|
||||
const authError = error as {
|
||||
__staleAuthInvalidation?: boolean;
|
||||
};
|
||||
|
||||
if (authError.__staleAuthInvalidation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const axiosError = error as AxiosError;
|
||||
const apiError = error as ApiError;
|
||||
const responseData = axiosError.response?.data as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const errorCode = responseData?.code || apiError.code;
|
||||
const errorMessage = responseData?.error || apiError.message;
|
||||
const status = axiosError.response?.status || apiError.status;
|
||||
const isMissingAuthenticationToken =
|
||||
errorMessage === "Missing authentication token";
|
||||
|
||||
return (
|
||||
status === 401 &&
|
||||
(errorCode === "SESSION_EXPIRED" ||
|
||||
errorCode === "SESSION_NOT_FOUND" ||
|
||||
(errorCode === "AUTH_REQUIRED" && userWasAuthenticated) ||
|
||||
errorMessage === "Invalid token" ||
|
||||
(errorMessage === "Authentication required" && userWasAuthenticated) ||
|
||||
(isMissingAuthenticationToken && userWasAuthenticated))
|
||||
);
|
||||
}
|
||||
|
||||
function createApiInstance(
|
||||
baseURL: string,
|
||||
@@ -282,8 +354,9 @@ function createApiInstance(
|
||||
const startTime = performance.now();
|
||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
(config as any).startTime = startTime;
|
||||
(config as any).requestId = requestId;
|
||||
const configWithMetadata = config as AxiosRequestConfigExtended;
|
||||
configWithMetadata.startTime = startTime;
|
||||
configWithMetadata.requestId = requestId;
|
||||
|
||||
const method = config.method?.toUpperCase() || "UNKNOWN";
|
||||
const url = config.url || "UNKNOWN";
|
||||
@@ -298,7 +371,6 @@ function createApiInstance(
|
||||
|
||||
const logger = getLoggerForService(serviceName);
|
||||
|
||||
const requestBaseURL = config.baseURL || "";
|
||||
const isDevMode = process.env.NODE_ENV === "development";
|
||||
|
||||
if (isDevMode) {
|
||||
@@ -311,19 +383,12 @@ function createApiInstance(
|
||||
} else {
|
||||
config.headers["X-Electron-App"] = "true";
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("jwt");
|
||||
if (token) {
|
||||
if (config.headers.set) {
|
||||
config.headers.set("Authorization", `Bearer ${token}`);
|
||||
} else {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
userWasAuthenticated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && (window as any).ReactNativeWebView) {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
(window as ElectronWindow).ReactNativeWebView
|
||||
) {
|
||||
let platform = "Unknown";
|
||||
if (typeof navigator !== "undefined" && navigator.userAgent) {
|
||||
if (navigator.userAgent.includes("Android")) {
|
||||
@@ -343,46 +408,15 @@ function createApiInstance(
|
||||
}
|
||||
}
|
||||
|
||||
if (!isElectron()) {
|
||||
const tokenCookie = document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("jwt="));
|
||||
|
||||
if (tokenCookie) {
|
||||
const tokenValue = tokenCookie.split("=")[1];
|
||||
if (tokenValue) {
|
||||
// Always add Authorization header as fallback if token is present,
|
||||
// especially important for cross-origin requests where cookies might be blocked
|
||||
const decodedToken = decodeURIComponent(tokenValue);
|
||||
if (config.headers.set) {
|
||||
config.headers.set("Authorization", `Bearer ${decodedToken}`);
|
||||
} else {
|
||||
config.headers["Authorization"] = `Bearer ${decodedToken}`;
|
||||
}
|
||||
userWasAuthenticated = true;
|
||||
}
|
||||
} else {
|
||||
// Check localStorage as fallback even in browser mode
|
||||
const localToken = localStorage.getItem("jwt");
|
||||
if (localToken) {
|
||||
if (config.headers.set) {
|
||||
config.headers.set("Authorization", `Bearer ${localToken}`);
|
||||
} else {
|
||||
config.headers["Authorization"] = `Bearer ${localToken}`;
|
||||
}
|
||||
userWasAuthenticated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
const endTime = performance.now();
|
||||
const startTime = (response.config as any).startTime;
|
||||
const requestId = (response.config as any).requestId;
|
||||
const responseConfig = response.config as AxiosRequestConfigExtended;
|
||||
const startTime = responseConfig.startTime;
|
||||
const requestId = responseConfig.requestId;
|
||||
const responseTime = Math.round(endTime - (startTime || endTime));
|
||||
|
||||
const method = response.config.method?.toUpperCase() || "UNKNOWN";
|
||||
@@ -452,7 +486,7 @@ function createApiInstance(
|
||||
const logger = getLoggerForService(serviceName);
|
||||
// A caller can mark a request as a silent retry (see progressive /status
|
||||
// retry) so we don't spam error logs / health events on each attempt.
|
||||
const isSilentRetry = !!(error.config as any)?.__silentRetry;
|
||||
const isSilentRetry = !!error.config?.__silentRetry;
|
||||
|
||||
if (process.env.NODE_ENV === "development" && !isSilentRetry) {
|
||||
if (status === 401) {
|
||||
@@ -478,36 +512,34 @@ function createApiInstance(
|
||||
?.error;
|
||||
const isSessionExpired = errorCode === "SESSION_EXPIRED";
|
||||
const isSessionNotFound = errorCode === "SESSION_NOT_FOUND";
|
||||
const isMissingAuthenticationToken =
|
||||
errorMessage === "Missing authentication token";
|
||||
const isInvalidToken =
|
||||
errorCode === "AUTH_REQUIRED" ||
|
||||
errorMessage === "Invalid token" ||
|
||||
errorMessage === "Authentication required" ||
|
||||
errorMessage === "Missing authentication token";
|
||||
(isMissingAuthenticationToken && userWasAuthenticated);
|
||||
|
||||
const headers = error.config?.headers;
|
||||
let hasAuthHeader = false;
|
||||
if (headers) {
|
||||
if (typeof headers.get === "function") {
|
||||
hasAuthHeader = !!(
|
||||
headers.get("Authorization") || headers.get("authorization")
|
||||
);
|
||||
} else {
|
||||
hasAuthHeader = !!(
|
||||
headers["Authorization"] || headers["authorization"]
|
||||
);
|
||||
if (isSessionExpired || isSessionNotFound || isInvalidToken) {
|
||||
const requestStartedAt =
|
||||
typeof error.config?.startTime === "number"
|
||||
? error.config.startTime
|
||||
: 0;
|
||||
const isStaleAuthInvalidation =
|
||||
latestAuthSuccessAt > 0 &&
|
||||
requestStartedAt > 0 &&
|
||||
requestStartedAt < latestAuthSuccessAt;
|
||||
|
||||
if (isStaleAuthInvalidation) {
|
||||
(
|
||||
error as { __staleAuthInvalidation?: boolean }
|
||||
).__staleAuthInvalidation = true;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(isSessionExpired || isSessionNotFound || isInvalidToken) &&
|
||||
hasAuthHeader
|
||||
) {
|
||||
const wasAuthenticated = userWasAuthenticated;
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
if (isElectron()) {
|
||||
electronSettingsCache.delete("jwt");
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: { clearSessionCookies?: () => Promise<void> };
|
||||
@@ -526,14 +558,12 @@ function createApiInstance(
|
||||
toast.warning("Session expired. Please log in again.");
|
||||
}
|
||||
|
||||
if (wasAuthenticated) {
|
||||
dbHealthMonitor.reportSessionExpired();
|
||||
}
|
||||
dbHealthMonitor.reportSessionExpired();
|
||||
|
||||
userWasAuthenticated = false;
|
||||
}
|
||||
} else if (!isSilentRetry) {
|
||||
const wasAuthenticated = !!localStorage.getItem("jwt");
|
||||
const wasAuthenticated = userWasAuthenticated;
|
||||
dbHealthMonitor.reportDatabaseError(error, wasAuthenticated);
|
||||
}
|
||||
|
||||
@@ -575,10 +605,7 @@ export interface ServerConfig {
|
||||
interface AxiosRequestConfigExtended extends AxiosRequestConfig {
|
||||
startTime?: number;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
interface AxiosResponseExtended extends AxiosResponse {
|
||||
config: AxiosRequestConfigExtended;
|
||||
__silentRetry?: boolean;
|
||||
}
|
||||
|
||||
interface AxiosErrorExtended extends AxiosError {
|
||||
@@ -643,10 +670,7 @@ export function getConfiguredServerUrl(): string | null {
|
||||
interface AxiosRequestConfigExtended extends AxiosRequestConfig {
|
||||
startTime?: number;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
interface AxiosResponseExtended extends AxiosResponse {
|
||||
config: AxiosRequestConfigExtended;
|
||||
__silentRetry?: boolean;
|
||||
}
|
||||
|
||||
interface AxiosErrorExtended extends AxiosError {
|
||||
@@ -677,7 +701,7 @@ export async function testServerConnection(
|
||||
|
||||
export async function checkElectronUpdate(): Promise<{
|
||||
success: boolean;
|
||||
status?: "up_to_date" | "requires_update";
|
||||
status?: "up_to_date" | "requires_update" | "beta";
|
||||
localVersion?: string;
|
||||
remoteVersion?: string;
|
||||
latest_release?: {
|
||||
@@ -949,7 +973,7 @@ function handleApiError(error: unknown, operation: string): never {
|
||||
? message
|
||||
: "Authentication required. Please log in again.";
|
||||
|
||||
throw new ApiError(errorMessage, 401, "AUTH_REQUIRED");
|
||||
throw new ApiError(errorMessage, 401, code || "AUTH_REQUIRED");
|
||||
} else if (status === 403) {
|
||||
authLogger.warn(`Access denied: ${method} ${url}`, errorContext);
|
||||
const apiError = new ApiError(
|
||||
@@ -1424,6 +1448,30 @@ export async function getTunnelStatuses(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeTunnelStatuses(
|
||||
onStatuses: (statuses: Record<string, TunnelStatus>) => void,
|
||||
onError?: () => void,
|
||||
): () => void {
|
||||
const baseURL = (tunnelApi.defaults.baseURL || "").replace(/\/$/, "");
|
||||
const source = new EventSource(`${baseURL}/tunnel/status/stream`, {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
source.addEventListener("statuses", (event) => {
|
||||
try {
|
||||
onStatuses(JSON.parse(event.data) as Record<string, TunnelStatus>);
|
||||
} catch {
|
||||
onError?.();
|
||||
}
|
||||
});
|
||||
|
||||
source.onerror = () => {
|
||||
onError?.();
|
||||
};
|
||||
|
||||
return () => source.close();
|
||||
}
|
||||
|
||||
export async function getTunnelStatusByName(
|
||||
tunnelName: string,
|
||||
): Promise<TunnelStatus | undefined> {
|
||||
@@ -1464,6 +1512,60 @@ export async function cancelTunnel(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getC2STunnelPresets(): Promise<C2STunnelPreset[]> {
|
||||
try {
|
||||
const response = await authApi.get("/c2s-tunnel-presets");
|
||||
return response.data || [];
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return [];
|
||||
}
|
||||
handleApiError(error, "fetch client tunnel presets");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createC2STunnelPreset(data: {
|
||||
name: string;
|
||||
config: TunnelConnection[];
|
||||
platform?: string;
|
||||
computerName?: string;
|
||||
}): Promise<C2STunnelPreset> {
|
||||
try {
|
||||
const response = await authApi.post("/c2s-tunnel-presets", data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create client tunnel preset");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateC2STunnelPreset(
|
||||
id: number,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
config: TunnelConnection[];
|
||||
platform: string;
|
||||
computerName: string;
|
||||
}>,
|
||||
): Promise<C2STunnelPreset> {
|
||||
try {
|
||||
const response = await authApi.put(`/c2s-tunnel-presets/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update client tunnel preset");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteC2STunnelPreset(
|
||||
id: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete(`/c2s-tunnel-presets/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete client tunnel preset");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FILE MANAGER METADATA (Recent, Pinned, Shortcuts)
|
||||
// ============================================================================
|
||||
@@ -1603,7 +1705,7 @@ export async function connectSSH(
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
jumpHosts?: any[];
|
||||
jumpHosts?: Array<{ hostId: number }>;
|
||||
},
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
@@ -1612,29 +1714,38 @@ export async function connectSSH(
|
||||
...config,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error?.response?.data?.connectionLogs) {
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.connectionLogs
|
||||
) {
|
||||
const data = error.response.data;
|
||||
const errorWithLogs = new Error(
|
||||
error?.response?.data?.error ||
|
||||
error?.response?.data?.message ||
|
||||
error.message,
|
||||
data.error || data.message || error.message,
|
||||
);
|
||||
(errorWithLogs as any).connectionLogs =
|
||||
error.response.data.connectionLogs;
|
||||
if (error.response.data.requires_totp) {
|
||||
(errorWithLogs as any).requires_totp = true;
|
||||
(errorWithLogs as any).sessionId = error.response.data.sessionId;
|
||||
(errorWithLogs as any).prompt = error.response.data.prompt;
|
||||
Object.assign(errorWithLogs, {
|
||||
connectionLogs: data.connectionLogs,
|
||||
});
|
||||
if (data.requires_totp) {
|
||||
Object.assign(errorWithLogs, {
|
||||
requires_totp: true,
|
||||
sessionId: data.sessionId,
|
||||
prompt: data.prompt,
|
||||
});
|
||||
}
|
||||
if (error.response.data.requires_warpgate) {
|
||||
(errorWithLogs as any).requires_warpgate = true;
|
||||
(errorWithLogs as any).sessionId = error.response.data.sessionId;
|
||||
(errorWithLogs as any).url = error.response.data.url;
|
||||
(errorWithLogs as any).securityKey = error.response.data.securityKey;
|
||||
if (data.requires_warpgate) {
|
||||
Object.assign(errorWithLogs, {
|
||||
requires_warpgate: true,
|
||||
sessionId: data.sessionId,
|
||||
url: data.url,
|
||||
securityKey: data.securityKey,
|
||||
});
|
||||
}
|
||||
if (error.response.data.status === "auth_required") {
|
||||
(errorWithLogs as any).status = "auth_required";
|
||||
(errorWithLogs as any).reason = error.response.data.reason;
|
||||
if (data.status === "auth_required") {
|
||||
Object.assign(errorWithLogs, {
|
||||
status: "auth_required",
|
||||
reason: data.reason,
|
||||
});
|
||||
}
|
||||
throw errorWithLogs;
|
||||
}
|
||||
@@ -2493,18 +2604,23 @@ export async function startMetricsPolling(hostId: number): Promise<{
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
viewerSessionId?: string;
|
||||
connectionLogs?: any[];
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post(`/metrics/start/${hostId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error?.response?.data?.connectionLogs) {
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.connectionLogs
|
||||
) {
|
||||
const data = error.response.data;
|
||||
const errorWithLogs = new Error(
|
||||
error?.response?.data?.error || error.message,
|
||||
data.error || data.message || error.message,
|
||||
);
|
||||
(errorWithLogs as any).connectionLogs =
|
||||
error.response.data.connectionLogs;
|
||||
Object.assign(errorWithLogs, {
|
||||
connectionLogs: data.connectionLogs,
|
||||
});
|
||||
throw errorWithLogs;
|
||||
}
|
||||
handleApiError(error, "start metrics polling");
|
||||
@@ -2733,23 +2849,14 @@ export async function loginUser(
|
||||
rememberMe,
|
||||
});
|
||||
|
||||
const hasToken = response.data.token;
|
||||
|
||||
if (isElectron() && hasToken) {
|
||||
localStorage.setItem("jwt", response.data.token);
|
||||
}
|
||||
|
||||
const isInIframe =
|
||||
typeof window !== "undefined" && window.self !== window.top;
|
||||
|
||||
if (isInIframe && isElectron() && hasToken) {
|
||||
localStorage.setItem("jwt", response.data.token);
|
||||
|
||||
if (isInIframe && isElectron() && response.data.success) {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
token: response.data.token,
|
||||
source: "login_api",
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
@@ -2761,8 +2868,11 @@ export async function loginUser(
|
||||
}
|
||||
}
|
||||
|
||||
if (response.data.success && !response.data.requires_totp) {
|
||||
markUserAuthenticated();
|
||||
}
|
||||
|
||||
return {
|
||||
token: response.data.token || "cookie-based",
|
||||
success: response.data.success,
|
||||
is_admin: response.data.is_admin,
|
||||
username: response.data.username,
|
||||
@@ -2788,8 +2898,6 @@ export async function logoutUser(): Promise<{
|
||||
clearTermixSessionStorage();
|
||||
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
electronSettingsCache.delete("jwt");
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: { clearSessionCookies?: () => Promise<void> };
|
||||
@@ -2799,8 +2907,8 @@ export async function logoutUser(): Promise<{
|
||||
} else {
|
||||
const isSecure = window.location.protocol === "https:";
|
||||
const cookieString = isSecure
|
||||
? "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; Secure; SameSite=Strict"
|
||||
: "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict";
|
||||
? "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; Secure; SameSite=Lax"
|
||||
: "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax";
|
||||
document.cookie = cookieString;
|
||||
}
|
||||
|
||||
@@ -2809,8 +2917,6 @@ export async function logoutUser(): Promise<{
|
||||
clearTermixSessionStorage();
|
||||
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
electronSettingsCache.delete("jwt");
|
||||
const electronAPI = (
|
||||
window as unknown as {
|
||||
electronAPI?: { clearSessionCookies?: () => Promise<void> };
|
||||
@@ -2820,8 +2926,8 @@ export async function logoutUser(): Promise<{
|
||||
} else {
|
||||
const isSecure = window.location.protocol === "https:";
|
||||
const cookieString = isSecure
|
||||
? "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; Secure; SameSite=Strict"
|
||||
: "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict";
|
||||
? "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; Secure; SameSite=Lax"
|
||||
: "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Lax";
|
||||
document.cookie = cookieString;
|
||||
}
|
||||
handleApiError(error, "logout user");
|
||||
@@ -2831,6 +2937,7 @@ export async function logoutUser(): Promise<{
|
||||
export async function getUserInfo(): Promise<UserInfo> {
|
||||
try {
|
||||
const response = await authApi.get("/users/me");
|
||||
markUserAuthenticated();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch user info");
|
||||
@@ -2997,8 +3104,8 @@ export async function getSessions(): Promise<{
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastActiveAt: string;
|
||||
jwtToken: string;
|
||||
isRevoked?: boolean;
|
||||
isCurrentSession?: boolean;
|
||||
}[];
|
||||
}> {
|
||||
try {
|
||||
@@ -3034,6 +3141,59 @@ export async function revokeAllUserSessions(
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
userId: string;
|
||||
username: string | null;
|
||||
tokenPrefix: string;
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface CreatedApiKey extends ApiKey {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export async function createApiKey(
|
||||
name: string,
|
||||
userId: string,
|
||||
expiresAt?: string,
|
||||
): Promise<CreatedApiKey> {
|
||||
try {
|
||||
const response = await authApi.post("/users/api-keys", {
|
||||
name,
|
||||
userId,
|
||||
expiresAt: expiresAt ?? null,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create API key");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getApiKeys(): Promise<{ apiKeys: ApiKey[] }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/api-keys");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch API keys");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteApiKey(
|
||||
keyId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await authApi.delete(`/users/api-keys/${keyId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete API key");
|
||||
}
|
||||
}
|
||||
|
||||
export async function makeUserAdmin(
|
||||
userId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
@@ -3207,23 +3367,14 @@ export async function verifyTOTPLogin(
|
||||
rememberMe,
|
||||
});
|
||||
|
||||
const hasToken = response.data.token;
|
||||
|
||||
if (isElectron() && hasToken) {
|
||||
localStorage.setItem("jwt", response.data.token);
|
||||
}
|
||||
|
||||
const isInIframe =
|
||||
typeof window !== "undefined" && window.self !== window.top;
|
||||
|
||||
if (isInIframe && isElectron() && hasToken) {
|
||||
localStorage.setItem("jwt", response.data.token);
|
||||
|
||||
if (isInIframe && isElectron() && response.data.success) {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
token: response.data.token,
|
||||
source: "totp_verify",
|
||||
platform: "desktop",
|
||||
timestamp: Date.now(),
|
||||
@@ -3235,6 +3386,10 @@ export async function verifyTOTPLogin(
|
||||
}
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
markUserAuthenticated();
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError, "verify TOTP login");
|
||||
@@ -3266,6 +3421,7 @@ export async function getUserAlerts(): Promise<{
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch user alerts");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3277,6 +3433,7 @@ export async function dismissAlert(
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "dismiss alert");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3757,9 +3914,30 @@ export async function executeSnippet(
|
||||
// MISCELLANEOUS API CALLS
|
||||
// ============================================================================
|
||||
|
||||
export interface NetworkTopologyNode {
|
||||
data: {
|
||||
id: string;
|
||||
label?: string;
|
||||
ip?: string;
|
||||
status?: string;
|
||||
tags?: string[];
|
||||
parent?: string;
|
||||
color?: string;
|
||||
};
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface NetworkTopologyEdge {
|
||||
data: {
|
||||
id?: string;
|
||||
source: string;
|
||||
target: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NetworkTopologyData {
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
nodes: NetworkTopologyNode[];
|
||||
edges: NetworkTopologyEdge[];
|
||||
}
|
||||
|
||||
export async function getNetworkTopology(): Promise<NetworkTopologyData | null> {
|
||||
@@ -4485,7 +4663,7 @@ export async function connectDockerSession(
|
||||
isPassword?: boolean;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
connectionLogs?: any[];
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
requires_warpgate?: boolean;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
@@ -4497,24 +4675,36 @@ export async function connectDockerSession(
|
||||
...config,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.status === "auth_required") {
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.status === "auth_required"
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (error.response?.data?.requires_totp) {
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.requires_totp
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (error.response?.data?.requires_warpgate) {
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.requires_warpgate
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (error?.response?.data?.connectionLogs) {
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.connectionLogs
|
||||
) {
|
||||
const data = error.response.data;
|
||||
const errorWithLogs = new Error(
|
||||
error?.response?.data?.error ||
|
||||
error?.response?.data?.message ||
|
||||
error.message,
|
||||
data.error || data.message || error.message,
|
||||
);
|
||||
(errorWithLogs as any).connectionLogs =
|
||||
error.response.data.connectionLogs;
|
||||
Object.assign(errorWithLogs, {
|
||||
connectionLogs: data.connectionLogs,
|
||||
});
|
||||
throw errorWithLogs;
|
||||
}
|
||||
throw handleApiError(error, "connect to Docker SSH session");
|
||||
|
||||
+43
-13
@@ -3,7 +3,6 @@ import React, {
|
||||
useEffect,
|
||||
Component,
|
||||
type FC,
|
||||
type ErrorInfo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Terminal } from "@/ui/mobile/apps/terminal/Terminal.tsx";
|
||||
@@ -14,13 +13,26 @@ import {
|
||||
TabProvider,
|
||||
useTabs,
|
||||
} from "@/ui/mobile/navigation/tabs/TabContext.tsx";
|
||||
import { getUserInfo } from "@/ui/main-axios.ts";
|
||||
import {
|
||||
getUserInfo,
|
||||
isCurrentAuthInvalidationError,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { Auth } from "@/ui/mobile/authentication/Auth.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
|
||||
|
||||
type ReactNativeWindow = Window & {
|
||||
ReactNativeWebView?: {
|
||||
postMessage: (message: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
function isReactNativeWebView(): boolean {
|
||||
return typeof window !== "undefined" && !!(window as any).ReactNativeWebView;
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
!!(window as ReactNativeWindow).ReactNativeWebView
|
||||
);
|
||||
}
|
||||
|
||||
const AppContent: FC = () => {
|
||||
@@ -33,6 +45,22 @@ const AppContent: FC = () => {
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [, setIsAdmin] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const isAuthenticatedRef = React.useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isAuthenticatedRef.current = isAuthenticated;
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSessionExpired = () => {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
};
|
||||
|
||||
dbHealthMonitor.on("session-expired", handleSessionExpired);
|
||||
return () => dbHealthMonitor.off("session-expired", handleSessionExpired);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
@@ -43,7 +71,6 @@ const AppContent: FC = () => {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
localStorage.removeItem("jwt");
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
@@ -51,15 +78,18 @@ const AppContent: FC = () => {
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
if (isCurrentAuthInvalidationError(err)) {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
console.warn(t("errors.sessionExpired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAuthenticatedRef.current) {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
}
|
||||
})
|
||||
.finally(() => setAuthLoading(false));
|
||||
@@ -249,7 +279,7 @@ class TabErrorBoundary extends Component<
|
||||
throw error;
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
componentDidCatch(error: Error) {
|
||||
if (error.message?.includes("useTabs must be used within a TabProvider")) {
|
||||
console.warn(
|
||||
"TabProvider mounting race condition detected, recovering...",
|
||||
|
||||
@@ -14,12 +14,7 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
isElectron,
|
||||
isEmbeddedMode,
|
||||
getCookie,
|
||||
getSnippets,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { isElectron, isEmbeddedMode, getSnippets } from "@/ui/main-axios.ts";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import {
|
||||
@@ -30,6 +25,7 @@ import {
|
||||
import type { TerminalConfig } from "@/types";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/dialogs/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/dialogs/SSHAuthDialog.tsx";
|
||||
import { PassphraseDialog } from "@/ui/desktop/navigation/dialogs/PassphraseDialog.tsx";
|
||||
import { WarpgateDialog } from "@/ui/desktop/navigation/dialogs/WarpgateDialog.tsx";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
@@ -99,7 +95,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [, setVisible] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
@@ -119,6 +115,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const [authDialogReason, setAuthDialogReason] = useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
>("no_keyboard");
|
||||
const [showPassphraseDialog, setShowPassphraseDialog] = useState(false);
|
||||
const [warpgateAuthRequired, setWarpgateAuthRequired] = useState(false);
|
||||
const [warpgateAuthUrl, setWarpgateAuthUrl] = useState<string>("");
|
||||
const [warpgateSecurityKey, setWarpgateSecurityKey] = useState<string>("");
|
||||
@@ -158,12 +155,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const jwtToken = getCookie("jwt");
|
||||
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
|
||||
|
||||
setIsAuthenticated((prev) => {
|
||||
if (prev !== isAuth) {
|
||||
return isAuth;
|
||||
if (!prev) {
|
||||
return true;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
@@ -333,6 +327,32 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
webSocketRef.current?.close();
|
||||
}
|
||||
|
||||
function handlePassphraseSubmit(passphrase: string) {
|
||||
if (webSocketRef.current && terminal) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "reconnect_with_credentials",
|
||||
data: {
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
keyPassword: passphrase,
|
||||
hostConfig: {
|
||||
...hostConfig,
|
||||
keyPassword: passphrase,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
setShowPassphraseDialog(false);
|
||||
setIsConnecting(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePassphraseCancel() {
|
||||
setShowPassphraseDialog(false);
|
||||
webSocketRef.current?.close();
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -452,21 +472,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
const jwtToken = getCookie("jwt");
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.warn("Reconnection cancelled - no authentication token");
|
||||
isReconnectingRef.current = false;
|
||||
updateConnectionError(t("terminal.authenticationRequired"));
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("terminal.authenticationRequired"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (terminal && hostConfig) {
|
||||
terminal.clear();
|
||||
const cols = terminal.cols;
|
||||
@@ -497,17 +502,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
const jwtToken = getCookie("jwt");
|
||||
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.error("No JWT token available for WebSocket connection");
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
updateConnectionError("Authentication required");
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseWsUrl = isDev
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
|
||||
: isElectron()
|
||||
@@ -543,9 +537,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const ws = new WebSocket(baseWsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
updateConnectionError(null);
|
||||
@@ -737,6 +729,13 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
} else if (msg.type === "session_ended") {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
shouldNotReconnectRef.current = true;
|
||||
isConnectingRef.current = false;
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
updateConnectionError(t("terminal.sessionEnded"));
|
||||
} else if (msg.type === "disconnected") {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
isConnectingRef.current = false;
|
||||
@@ -810,6 +809,13 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
} else if (msg.type === "passphrase_required") {
|
||||
setShowPassphraseDialog(true);
|
||||
setIsConnecting(false);
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
} else if (msg.type === "tmux_sessions_available") {
|
||||
// On mobile, auto-attach to the first available session
|
||||
const sessions = msg.sessions as Array<{ name: string }>;
|
||||
@@ -913,8 +919,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1078,16 +1082,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
hardRefresh();
|
||||
|
||||
const jwtToken = getCookie("jwt");
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
updateConnectionError("Authentication required");
|
||||
setVisible(true);
|
||||
setIsReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
|
||||
@@ -1206,6 +1200,19 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
<PassphraseDialog
|
||||
isOpen={showPassphraseDialog}
|
||||
onSubmit={handlePassphraseSubmit}
|
||||
onCancel={handlePassphraseCancel}
|
||||
hostInfo={{
|
||||
ip: hostConfig.ip,
|
||||
port: hostConfig.port,
|
||||
username: hostConfig.username,
|
||||
name: hostConfig.name,
|
||||
}}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
<WarpgateDialog
|
||||
isOpen={warpgateAuthRequired}
|
||||
url={warpgateAuthUrl}
|
||||
|
||||
@@ -22,32 +22,31 @@ import {
|
||||
completePasswordReset,
|
||||
getOIDCAuthorizeUrl,
|
||||
verifyTOTPLogin,
|
||||
logoutUser,
|
||||
isElectron,
|
||||
getCookie,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
|
||||
type ReactNativeWindow = Window & {
|
||||
ReactNativeWebView?: {
|
||||
postMessage: (message: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
function isReactNativeWebView(): boolean {
|
||||
return typeof window !== "undefined" && !!(window as any).ReactNativeWebView;
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
!!(window as ReactNativeWindow).ReactNativeWebView
|
||||
);
|
||||
}
|
||||
|
||||
function postJWTToWebView() {
|
||||
function postAuthSuccessToWebView() {
|
||||
if (!isReactNativeWebView()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const jwt = getCookie("jwt") || localStorage.getItem("jwt");
|
||||
|
||||
if (!jwt) {
|
||||
return;
|
||||
}
|
||||
|
||||
(window as any).ReactNativeWebView.postMessage(
|
||||
(window as ReactNativeWindow).ReactNativeWebView?.postMessage(
|
||||
JSON.stringify({
|
||||
type: "AUTH_SUCCESS",
|
||||
token: jwt,
|
||||
source: "explicit",
|
||||
platform: "mobile",
|
||||
timestamp: Date.now(),
|
||||
@@ -263,7 +262,7 @@ export function Auth({
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
postJWTToWebView();
|
||||
postAuthSuccessToWebView();
|
||||
|
||||
if (isReactNativeWebView()) {
|
||||
setMobileAuthSuccess(true);
|
||||
@@ -458,16 +457,12 @@ export function Auth({
|
||||
throw new Error(t("errors.loginFailed"));
|
||||
}
|
||||
|
||||
if (isElectron() && res.token) {
|
||||
localStorage.setItem("jwt", res.token);
|
||||
}
|
||||
|
||||
setIsAdmin(!!res.is_admin);
|
||||
setUsername(res.username || null);
|
||||
setUserId(res.userId || null);
|
||||
setDbError(null);
|
||||
|
||||
postJWTToWebView();
|
||||
postAuthSuccessToWebView();
|
||||
|
||||
if (isReactNativeWebView()) {
|
||||
setMobileAuthSuccess(true);
|
||||
@@ -589,50 +584,43 @@ export function Auth({
|
||||
setOidcLoading(true);
|
||||
setError(null);
|
||||
|
||||
const urlToken = urlParams.get("token");
|
||||
if (urlToken && (isElectron() || isReactNativeWebView())) {
|
||||
localStorage.setItem("jwt", urlToken);
|
||||
}
|
||||
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
setTimeout(() => {
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
postJWTToWebView();
|
||||
if (isReactNativeWebView()) {
|
||||
postAuthSuccessToWebView();
|
||||
setMobileAuthSuccess(true);
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReactNativeWebView()) {
|
||||
setMobileAuthSuccess(true);
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
}
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
|
||||
setLoggedIn(true);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get user info after OIDC callback:", err);
|
||||
setError(t("errors.failedUserInfo"));
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
})
|
||||
.finally(() => {
|
||||
setOidcLoading(false);
|
||||
setLoggedIn(true);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
}, 200);
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get user info after OIDC callback:", err);
|
||||
setError(t("errors.failedUserInfo"));
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
})
|
||||
.finally(() => {
|
||||
setOidcLoading(false);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { ChevronUp, Menu, User2, Moon, Sun } from "lucide-react";
|
||||
import { ChevronUp, Menu, User2 } from "lucide-react";
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { FolderCard } from "@/ui/mobile/navigation/hosts/FolderCard.tsx";
|
||||
|
||||
Reference in New Issue
Block a user