fix: Several bug fixes for terminals, server stats, and general feature improvements

This commit is contained in:
LukeGus
2025-11-08 15:23:14 -06:00
parent c69d31062e
commit b43e98073f
16 changed files with 445 additions and 209 deletions

View File

@@ -640,6 +640,9 @@ export function CredentialsManager({
<p className="text-xs text-muted-foreground truncate">
{credential.username}
</p>
<p className="text-xs text-muted-foreground truncate">
ID: {credential.id}
</p>
<p className="text-xs text-muted-foreground truncate">
{credential.authType === "password"
? t("credentials.password")

View File

@@ -527,41 +527,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const reader = new FileReader();
reader.onerror = () => reject(reader.error);
const isTextFile =
file.type.startsWith("text/") ||
file.type === "application/json" ||
file.type === "application/javascript" ||
file.type === "application/xml" ||
file.type === "image/svg+xml" ||
file.name.match(
/\.(txt|json|js|ts|jsx|tsx|css|scss|less|html|htm|xml|svg|yaml|yml|md|markdown|mdown|mkdn|mdx|py|java|c|cpp|h|sh|bash|zsh|bat|ps1|toml|ini|conf|config|sql|vue|svelte)$/i,
);
if (isTextFile) {
reader.onload = () => {
if (reader.result) {
resolve(reader.result as string);
} else {
reject(new Error("Failed to read text file content"));
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]);
}
};
reader.readAsText(file);
} else {
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);
resolve(base64);
} else {
reject(new Error("Failed to read binary file"));
}
};
reader.readAsArrayBuffer(file);
}
const base64 = btoa(binary);
resolve(base64);
} else {
reject(new Error("Failed to read file"));
}
};
reader.readAsArrayBuffer(file);
});
await uploadSSHFile(

View File

@@ -217,6 +217,7 @@ export function HostManagerEditor({
pin: z.boolean().default(false),
authType: z.enum(["password", "key", "credential", "none"]),
credentialId: z.number().optional().nullable(),
overrideCredentialUsername: z.boolean().optional(),
password: z.string().optional(),
key: z.any().optional().nullable(),
keyPassword: z.string().optional(),
@@ -389,6 +390,7 @@ export function HostManagerEditor({
pin: false,
authType: "password" as const,
credentialId: null,
overrideCredentialUsername: false,
password: "",
key: null,
keyPassword: "",
@@ -407,7 +409,8 @@ export function HostManagerEditor({
useEffect(() => {
if (authTab === "credential") {
const currentCredentialId = form.getValues("credentialId");
if (currentCredentialId) {
const overrideUsername = form.getValues("overrideCredentialUsername");
if (currentCredentialId && !overrideUsername) {
const selectedCredential = credentials.find(
(c) => c.id === currentCredentialId,
);
@@ -464,6 +467,9 @@ export function HostManagerEditor({
pin: Boolean(cleanedHost.pin),
authType: defaultAuthType as "password" | "key" | "credential" | "none",
credentialId: null,
overrideCredentialUsername: Boolean(
cleanedHost.overrideCredentialUsername,
),
password: "",
key: null,
keyPassword: "",
@@ -512,6 +518,7 @@ export function HostManagerEditor({
pin: false,
authType: "password" as const,
credentialId: null,
overrideCredentialUsername: false,
password: "",
key: null,
keyPassword: "",
@@ -574,6 +581,7 @@ export function HostManagerEditor({
tags: data.tags || [],
pin: Boolean(data.pin),
authType: data.authType,
overrideCredentialUsername: Boolean(data.overrideCredentialUsername),
enableTerminal: Boolean(data.enableTerminal),
enableTunnel: Boolean(data.enableTunnel),
enableFileManager: Boolean(data.enableFileManager),
@@ -882,17 +890,28 @@ export function HostManagerEditor({
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="col-span-6">
<FormLabel>{t("hosts.username")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.username")}
{...field}
/>
</FormControl>
</FormItem>
)}
render={({ field }) => {
const isCredentialAuth = authTab === "credential";
const hasCredential = !!form.watch("credentialId");
const overrideEnabled = !!form.watch(
"overrideCredentialUsername",
);
const shouldDisable =
isCredentialAuth && hasCredential && !overrideEnabled;
return (
<FormItem className="col-span-6">
<FormLabel>{t("hosts.username")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.username")}
disabled={shouldDisable}
{...field}
/>
</FormControl>
</FormItem>
);
}}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">
@@ -1263,29 +1282,60 @@ export function HostManagerEditor({
</div>
</TabsContent>
<TabsContent value="credential">
<FormField
control={form.control}
name="credentialId"
render={({ field }) => (
<FormItem>
<CredentialSelector
value={field.value}
onValueChange={field.onChange}
onCredentialSelect={(credential) => {
if (credential) {
form.setValue(
"username",
credential.username,
);
}
}}
/>
<FormDescription>
{t("hosts.credentialDescription")}
</FormDescription>
</FormItem>
<div className="space-y-4">
<FormField
control={form.control}
name="credentialId"
render={({ field }) => (
<FormItem>
<CredentialSelector
value={field.value}
onValueChange={field.onChange}
onCredentialSelect={(credential) => {
if (
credential &&
!form.getValues(
"overrideCredentialUsername",
)
) {
form.setValue(
"username",
credential.username,
);
}
}}
/>
<FormDescription>
{t("hosts.credentialDescription")}
</FormDescription>
</FormItem>
)}
/>
{form.watch("credentialId") && (
<FormField
control={form.control}
name="overrideCredentialUsername"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>
{t("hosts.overrideCredentialUsername")}
</FormLabel>
<FormDescription>
{t("hosts.overrideCredentialUsernameDesc")}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
/>
</div>
</TabsContent>
<TabsContent value="none">
<Alert className="mt-2">

View File

@@ -112,6 +112,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
useState(false);
const isVisibleRef = useRef<boolean>(false);
const isReadyRef = useRef<boolean>(false);
const isFittingRef = useRef(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0);
@@ -157,6 +158,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
isVisibleRef.current = isVisible;
}, [isVisible]);
useEffect(() => {
isReadyRef.current = isReady;
}, [isReady]);
useEffect(() => {
const checkAuth = () => {
const jwtToken = getCookie("jwt");
@@ -507,6 +512,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}),
);
terminal.onData((data) => {
if (data === "\x00" || data === "\u0000") {
return;
}
ws.send(JSON.stringify({ type: "input", data }));
});
@@ -915,15 +923,21 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
element?.addEventListener("keydown", handleMacKeyboard, true);
const resizeObserver = new ResizeObserver(() => {
const handleResize = () => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => {
if (!isVisibleRef.current || !isReady) return;
if (!isVisibleRef.current || !isReadyRef.current) return;
performFit();
}, 50);
});
}, 100);
};
resizeObserver.observe(xtermRef.current);
const resizeObserver = new ResizeObserver(handleResize);
if (xtermRef.current) {
resizeObserver.observe(xtermRef.current);
}
window.addEventListener("resize", handleResize);
setVisible(true);
@@ -936,6 +950,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
setIsReady(false);
isFittingRef.current = false;
resizeObserver.disconnect();
window.removeEventListener("resize", handleResize);
element?.removeEventListener("contextmenu", handleContextMenu);
element?.removeEventListener("keydown", handleMacKeyboard, true);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);

View File

@@ -558,65 +558,61 @@ export function Auth({
if (success) {
setOidcLoading(true);
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);
setTimeout(() => window.location.reload(), 100);
setOidcLoading(false);
return;
} catch (e) {
console.error("Error posting auth success message:", e);
// Clear the success parameter first to prevent re-processing
window.history.replaceState({}, document.title, window.location.pathname);
setTimeout(() => {
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);
setTimeout(() => window.location.reload(), 100);
setOidcLoading(false);
return;
} catch (e) {
console.error("Error posting auth success message:", e);
}
}
}
}
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null,
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
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);
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
})
.finally(() => {
setOidcLoading(false);
});
setInternalLoggedIn(true);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
})
.catch(() => {
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
})
.finally(() => {
setOidcLoading(false);
});
}, 200);
}
}, [
onAuthSuccess,

View File

@@ -318,34 +318,37 @@ function createApiInstance(
const errorMessage = (error.response?.data as Record<string, unknown>)
?.error;
const isSessionExpired = errorCode === "SESSION_EXPIRED";
const isSessionNotFound = errorCode === "SESSION_NOT_FOUND";
const isInvalidToken =
errorCode === "AUTH_REQUIRED" ||
errorMessage === "Invalid token" ||
errorMessage === "Authentication required";
if (isElectron()) {
localStorage.removeItem("jwt");
} else {
localStorage.removeItem("jwt");
}
if (isSessionExpired || isSessionNotFound) {
if (isElectron()) {
localStorage.removeItem("jwt");
} else {
localStorage.removeItem("jwt");
}
if (
(isSessionExpired || isInvalidToken) &&
typeof window !== "undefined"
) {
if (typeof window !== "undefined") {
console.warn("Session expired or not found - 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.");
window.location.reload();
});
setTimeout(() => window.location.reload(), 1000);
}
} else if (isInvalidToken && typeof window !== "undefined") {
console.warn(
"Session expired or invalid token - please log in again",
"Authentication error - token may be invalid",
errorMessage,
);
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.");
window.location.reload();
});
setTimeout(() => window.location.reload(), 1000);
}
}
@@ -792,6 +795,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
keyType: hostData.authType === "key" ? hostData.keyType : null,
credentialId:
hostData.authType === "credential" ? hostData.credentialId : null,
overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername),
enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager),
@@ -855,6 +859,7 @@ export async function updateSSHHost(
keyType: hostData.authType === "key" ? hostData.keyType : null,
credentialId:
hostData.authType === "credential" ? hostData.credentialId : null,
overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername),
enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager),

View File

@@ -504,55 +504,45 @@ export function Auth({
setOidcLoading(true);
setError(null);
getUserInfo()
.then((meRes) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
postJWTToWebView();
window.history.replaceState({}, document.title, window.location.pathname);
if (isReactNativeWebView()) {
setMobileAuthSuccess(true);
setTimeout(() => {
getUserInfo()
.then((meRes) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
postJWTToWebView();
if (isReactNativeWebView()) {
setMobileAuthSuccess(true);
setOidcLoading(false);
return;
}
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);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
return;
}
setLoggedIn(true);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null,
});
setInternalLoggedIn(true);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
})
.catch(() => {
setError(t("errors.failedUserInfo"));
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
})
.finally(() => {
setOidcLoading(false);
});
}, 200);
}
}, []);