fix: Sidebar resize issues and issues with TOTP interfering with password auth

This commit is contained in:
LukeGus
2025-11-02 15:44:25 -06:00
parent 9a697a7c10
commit 855a2b5a64
19 changed files with 338 additions and 218 deletions

View File

@@ -298,8 +298,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
[systemDrag, clearSelection],
);
const isConnectingRef = useRef(false);
async function initializeSSHConnection() {
if (!currentHost) return;
if (!currentHost || isConnectingRef.current) return;
isConnectingRef.current = true;
try {
setIsLoading(true);
@@ -318,6 +322,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
authType: currentHost.authType,
credentialId: currentHost.credentialId,
userId: currentHost.userId,
forceKeyboardInteractive: currentHost.forceKeyboardInteractive,
});
if (result?.requires_totp) {
@@ -359,6 +364,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
);
} finally {
setIsLoading(false);
isConnectingRef.current = false;
}
}

View File

@@ -311,6 +311,7 @@ export function HostManagerEditor({
moshCommand: z.string(),
})
.optional(),
forceKeyboardInteractive: z.boolean().optional(),
})
.superRefine((data, ctx) => {
if (data.authType === "none") {
@@ -399,6 +400,7 @@ export function HostManagerEditor({
tunnelConnections: [],
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
},
});
@@ -473,6 +475,7 @@ export function HostManagerEditor({
tunnelConnections: cleanedHost.tunnelConnections || [],
statsConfig: parsedStatsConfig,
terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
};
if (defaultAuthType === "password") {
@@ -520,6 +523,7 @@ export function HostManagerEditor({
tunnelConnections: [],
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
};
form.reset(defaultFormData);
@@ -577,6 +581,7 @@ export function HostManagerEditor({
tunnelConnections: data.tunnelConnections || [],
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive),
};
submitData.credentialId = null;
@@ -1296,6 +1301,28 @@ export function HostManagerEditor({
</Alert>
</TabsContent>
</Tabs>
<FormField
control={form.control}
name="forceKeyboardInteractive"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 mt-4">
<div className="space-y-0.5">
<FormLabel>
{t("hosts.forceKeyboardInteractive")}
</FormLabel>
<FormDescription>
{t("hosts.forceKeyboardInteractiveDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="terminal" className="space-y-1">
<FormField

View File

@@ -698,7 +698,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
localStorage.removeItem("jwt");
toast.error("Authentication failed. Please log in again.");
setTimeout(() => {
window.location.reload();
}, 1000);
return;
}

View File

@@ -1,5 +1,12 @@
import React, { useState } from "react";
import { ChevronUp, User2, HardDrive, Menu, ChevronRight } from "lucide-react";
import {
ChevronUp,
User2,
HardDrive,
Menu,
ChevronRight,
RotateCcw,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { isElectron, logoutUser } from "@/ui/main-axios.ts";
@@ -241,10 +248,9 @@ export function LeftSidebar({
localStorage.setItem("leftSidebarOpen", JSON.stringify(isSidebarOpen));
}, [isSidebarOpen]);
// Sidebar width state for resizing
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
const saved = localStorage.getItem("leftSidebarWidth");
return saved !== null ? parseInt(saved, 10) : 320;
return saved !== null ? parseInt(saved, 10) : 250;
});
const [isResizing, setIsResizing] = useState(false);
@@ -350,151 +356,171 @@ export function LeftSidebar({
return (
<div className="min-h-svh">
<SidebarProvider
<SidebarProvider
open={isSidebarOpen}
style={{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties}
style={
{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties
}
>
<div className="flex h-screen w-full">
<Sidebar variant="floating" className="">
<SidebarHeader>
<SidebarGroupLabel className="text-lg font-bold text-white">
Termix
<Button
variant="outline"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="w-[28px] h-[28px] absolute right-5"
title={t("common.toggleSidebar")}
>
<Menu className="h-4 w-4" />
</Button>
</SidebarGroupLabel>
</SidebarHeader>
<Separator className="p-0.25" />
<SidebarContent>
<SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button
className="m-2 flex flex-row font-semibold border-2 !border-dark-border"
variant="outline"
onClick={openSshManagerTab}
disabled={!!sshManagerTab || isSplitScreenActive}
title={
sshManagerTab
? t("interface.sshManagerAlreadyOpen")
: isSplitScreenActive
? t("interface.disabledDuringSplitScreen")
: undefined
}
>
<HardDrive strokeWidth="2.5" />
{t("nav.hostManager")}
</Button>
</SidebarGroup>
<Separator className="p-0.25" />
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
<div className="!bg-dark-bg-input rounded-lg">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("placeholders.searchHostsAny")}
className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md"
autoComplete="off"
/>
</div>
{hostsError && (
<div className="!bg-dark-bg-input rounded-lg">
<div className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md px-3 py-1.5 flex items-center text-red-500">
{t("leftSidebar.failedToLoadHosts")}
</div>
</div>
)}
{hostsLoading && (
<div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center">
{t("hosts.loadingHosts")}
</div>
</div>
)}
{sortedFolders.map((folder, idx) => (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
isLast={idx === sortedFolders.length - 1}
/>
))}
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:opacity-90 w-full"
disabled={disabled}
>
<User2 /> {username ? username : t("common.logout")}
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={6}
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
<Sidebar variant="floating">
<SidebarHeader>
<SidebarGroupLabel className="text-lg font-bold text-white">
Termix
<div className="absolute right-5 flex gap-1">
<Button
variant="outline"
onClick={() => setSidebarWidth(250)}
className="w-[28px] h-[28px]"
title="Reset sidebar width"
>
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => {
openUserProfileTab();
}}
<RotateCcw className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="w-[28px] h-[28px]"
title={t("common.toggleSidebar")}
>
<Menu className="h-4 w-4" />
</Button>
</div>
</SidebarGroupLabel>
</SidebarHeader>
<Separator className="p-0.25" />
<SidebarContent>
<SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button
className="m-2 flex flex-row font-semibold border-2 !border-dark-border"
variant="outline"
onClick={openSshManagerTab}
disabled={!!sshManagerTab || isSplitScreenActive}
title={
sshManagerTab
? t("interface.sshManagerAlreadyOpen")
: isSplitScreenActive
? t("interface.disabledDuringSplitScreen")
: undefined
}
>
<HardDrive strokeWidth="2.5" />
{t("nav.hostManager")}
</Button>
</SidebarGroup>
<Separator className="p-0.25" />
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
<div className="!bg-dark-bg-input rounded-lg">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("placeholders.searchHostsAny")}
className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md"
autoComplete="off"
/>
</div>
{hostsError && (
<div className="!bg-dark-bg-input rounded-lg">
<div className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md px-3 py-1.5 flex items-center text-red-500">
{t("leftSidebar.failedToLoadHosts")}
</div>
</div>
)}
{hostsLoading && (
<div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center">
{t("hosts.loadingHosts")}
</div>
</div>
)}
{sortedFolders.map((folder, idx) => (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
isLast={idx === sortedFolders.length - 1}
/>
))}
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1" />
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:opacity-90 w-full"
disabled={disabled}
>
<User2 /> {username ? username : t("common.logout")}
<ChevronUp className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={6}
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
>
<span>{t("profile.title")}</span>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => {
if (isAdmin) openAdminTab();
openUserProfileTab();
}}
>
<span>{t("admin.title")}</span>
<span>{t("profile.title")}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={handleLogout}
>
<span>{t("common.logout")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
{/* Resizable divider */}
{isSidebarOpen && (
<div
className="w-4 cursor-col-resize h-screen z-50 bg-transparent hover:bg-dark-border/30 flex items-center justify-center"
onMouseDown={handleMouseDown}
title="Drag to resize sidebar"
>
<div
className={`w-1 h-full transition-colors duration-200 ${
isResizing
? "bg-dark-active"
: "bg-dark-border hover:bg-dark-border-hover"
}`}
{isAdmin && (
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => {
if (isAdmin) openAdminTab();
}}
>
<span>{t("admin.title")}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={handleLogout}
>
<span>{t("common.logout")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
{isSidebarOpen && (
<div
className="absolute top-0 h-full cursor-col-resize z-[60]"
onMouseDown={handleMouseDown}
style={{
right: "-8px",
width: "18px",
backgroundColor: isResizing
? "var(--dark-active)"
: "transparent",
}}
onMouseEnter={(e) => {
if (!isResizing) {
e.currentTarget.style.backgroundColor =
"var(--dark-border-hover)";
}
}}
onMouseLeave={(e) => {
if (!isResizing) {
e.currentTarget.style.backgroundColor = "transparent";
}
}}
title="Drag to resize sidebar"
/>
</div>
)}
)}
</Sidebar>
<SidebarInset>{children}</SidebarInset>
</div>

View File

@@ -42,7 +42,7 @@ export function SSHAuthDialog({
onSubmit,
onCancel,
hostInfo,
backgroundColor = "#1e1e1e",
backgroundColor = "#18181b",
}: SSHAuthDialogProps) {
const { t } = useTranslation();
const [authTab, setAuthTab] = useState<"password" | "key">("password");
@@ -136,7 +136,10 @@ export function SSHAuthDialog({
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-dark-bg">
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-dark-bg"
style={{ backgroundColor }}
>
<Card className="w-full max-w-2xl mx-4 border-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">

View File

@@ -50,8 +50,8 @@ export function TopNavbar({
allSplitScreenTab: number[];
reorderTabs: (fromIndex: number, toIndex: number) => void;
};
// Use CSS variable for dynamic sidebar width + divider width (4px) + some padding
const leftPosition = state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 20px)";
const leftPosition =
state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)";
const { t } = useTranslation();
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
@@ -301,7 +301,7 @@ export function TopNavbar({
top: isTopbarOpen ? "0.5rem" : "-3rem",
left: leftPosition,
right: "17px",
backgroundColor: "#1e1e21",
backgroundColor: "#18181b",
}}
>
<div
@@ -491,8 +491,7 @@ export function TopNavbar({
{!isTopbarOpen && (
<div
onClick={() => setIsTopbarOpen(true)}
className="absolute top-0 left-0 w-full h-[10px] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md"
style={{ backgroundColor: "#1e1e21" }}
className="absolute top-0 left-0 w-full h-[10px] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md bg-dark"
>
<ChevronDown size={10} />
</div>

View File

@@ -316,7 +316,13 @@ function createApiInstance(
if (status === 401) {
const errorCode = (error.response?.data as Record<string, unknown>)
?.code;
const errorMessage = (error.response?.data as Record<string, unknown>)
?.error;
const isSessionExpired = errorCode === "SESSION_EXPIRED";
const isInvalidToken =
errorCode === "AUTH_REQUIRED" ||
errorMessage === "Invalid token" ||
errorMessage === "Authentication required";
if (isElectron()) {
localStorage.removeItem("jwt");
@@ -324,17 +330,22 @@ function createApiInstance(
localStorage.removeItem("jwt");
}
if (isSessionExpired && typeof window !== "undefined") {
console.warn("Session expired - please log in again");
if (
(isSessionExpired || isInvalidToken) &&
typeof window !== "undefined"
) {
console.warn(
"Session expired or invalid token - please log in again",
);
document.cookie =
"jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
import("sonner").then(({ toast }) => {
toast.warning("Session expired - please log in again");
toast.warning("Session expired. Please log in again.");
});
setTimeout(() => window.location.reload(), 100);
setTimeout(() => window.location.reload(), 1000);
}
}
@@ -792,6 +803,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
: JSON.stringify(hostData.statsConfig)
: null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
};
if (!submitData.enableTunnel) {
@@ -854,6 +866,7 @@ export async function updateSSHHost(
: JSON.stringify(hostData.statsConfig)
: null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
};
if (!submitData.enableTunnel) {
@@ -1164,6 +1177,7 @@ export async function connectSSH(
authType?: string;
credentialId?: number;
userId?: string;
forceKeyboardInteractive?: boolean;
},
): Promise<Record<string, unknown>> {
try {