fix: Improve TOTP reliability, move components around, turn homepage update log into a sheet

This commit is contained in:
LukeGus
2025-10-15 00:14:54 -05:00
parent e2591d616b
commit 57cfea2ca8
19 changed files with 360 additions and 3164 deletions

View File

@@ -17,7 +17,7 @@ import {
AlertCircle,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TermixAlert } from "../../../types/index.js";
import type { TermixAlert } from "../../../../../../types";
interface AlertCardProps {
alert: TermixAlert;
@@ -67,7 +67,7 @@ const getTypeBadgeVariant = (type?: string) => {
}
};
export function HomepageAlertCard({
export function AlertCard({
alert,
onDismiss,
onClose,

View File

@@ -1,16 +1,16 @@
import React, { useEffect, useState } from "react";
import { HomepageAlertCard } from "./HomepageAlertCard.tsx";
import { AlertCard } from "./AlertCard.tsx";
import { Button } from "@/components/ui/button.tsx";
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import type { TermixAlert } from "../../../types/index.js";
import type { TermixAlert } from "../../../../../../types";
interface AlertManagerProps {
userId: string | null;
loggedIn: boolean;
}
export function HomepageAlertManager({
export function AlertManager({
userId,
loggedIn,
}: AlertManagerProps): React.ReactElement {
@@ -129,7 +129,7 @@ export function HomepageAlertManager({
return (
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
<div className="relative w-full max-w-2xl mx-4">
<HomepageAlertCard
<AlertCard
alert={currentAlert}
onDismiss={handleDismissAlert}
onClose={handleCloseCurrentAlert}

View File

@@ -0,0 +1,218 @@
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 { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import { BookOpen, X } from "lucide-react";
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
loggedIn: boolean;
}
interface ReleaseItem {
id: number;
title: string;
description: string;
link: string;
pubDate: string;
version: string;
isPrerelease: boolean;
isDraft: boolean;
assets: Array<{
name: string;
size: number;
download_count: number;
download_url: string;
}>;
}
interface RSSResponse {
feed: {
title: string;
description: string;
link: string;
updated: string;
};
items: ReleaseItem[];
total_count: number;
cached: boolean;
cache_age?: number;
}
interface VersionResponse {
status: "up_to_date" | "requires_update";
version: string;
latest_release: {
name: string;
published_at: string;
html_url: string;
};
cached: boolean;
cache_age?: number;
}
export function HomepageUpdateLog({ loggedIn }: HomepageUpdateLogProps) {
const { t } = useTranslation();
const [releases, setReleases] = useState<RSSResponse | null>(null);
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (loggedIn && isOpen) {
setLoading(true);
Promise.all([getReleasesRSS(100), getVersionInfo()])
.then(([releasesRes, versionRes]) => {
setReleases(releasesRes);
setVersionInfo(versionRes);
setError(null);
})
.catch(() => {
setError(t("common.failedToFetchUpdateInfo"));
})
.finally(() => setLoading(false));
}
}, [loggedIn, isOpen]);
if (!loggedIn) {
return null;
}
const formatDescription = (description: string) => {
const firstLine = description.split("\n")[0];
return firstLine.replace(/[#*`]/g, "").replace(/\s+/g, " ").trim();
};
return (
<>
<Button
variant="outline"
size="lg"
className="text-sm border-2 border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg-darker transition-colors"
onClick={() => setIsOpen(true)}
>
<BookOpen className="w-4 h-4 mr-2" />
{t("common.updatesAndReleases")}
</Button>
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetContent
side="right"
className="w-[500px] bg-dark-bg border-l-2 border-dark-border text-white sm:max-w-[500px] p-0 flex flex-col [&>button]:hidden"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-border">
<h2 className="text-lg font-semibold text-white">
{t("common.updatesAndReleases")}
</h2>
<Button
variant="outline"
size="sm"
onClick={() => setIsOpen(false)}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title={t("common.close")}
>
<X />
</Button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{versionInfo && versionInfo.status === "requires_update" && (
<Alert className="bg-dark-bg-darker border-dark-border text-white mb-3">
<AlertTitle className="text-white">
{t("common.updateAvailable")}
</AlertTitle>
<AlertDescription className="text-gray-300">
{t("common.newVersionAvailable", {
version: versionInfo.version,
})}
</AlertDescription>
</Alert>
)}
{loading && (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
)}
{error && (
<Alert
variant="destructive"
className="bg-red-900/20 border-red-500 text-red-300 mb-3"
>
<AlertTitle className="text-red-300">
{t("common.error")}
</AlertTitle>
<AlertDescription className="text-red-300">
{error}
</AlertDescription>
</Alert>
)}
<div className="space-y-3">
{releases?.items.map((release) => (
<div
key={release.id}
className="border border-dark-border rounded-lg p-3 hover:bg-dark-bg-darker transition-colors cursor-pointer bg-dark-bg-darker/50"
onClick={() => window.open(release.link, "_blank")}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-sm leading-tight flex-1 text-white">
{release.title}
</h4>
{release.isPrerelease && (
<span className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
{t("common.preRelease")}
</span>
)}
</div>
<p className="text-xs text-gray-300 mb-2 leading-relaxed">
{formatDescription(release.description)}
</p>
<div className="flex items-center text-xs text-gray-400">
<span>
{new Date(release.pubDate).toLocaleDateString()}
</span>
{release.assets.length > 0 && (
<>
<span className="mx-2"></span>
<span>
{release.assets.length} asset
{release.assets.length !== 1 ? "s" : ""}
</span>
</>
)}
</div>
</div>
))}
</div>
{releases && releases.items.length === 0 && !loading && (
<Alert className="bg-dark-bg-darker border-dark-border text-gray-300">
<AlertTitle className="text-gray-300">
{t("common.noReleases")}
</AlertTitle>
<AlertDescription className="text-gray-400">
{t("common.noReleasesFound")}
</AlertDescription>
</Alert>
)}
</div>
</SheetContent>
</Sheet>
</>
);
}

View File

@@ -1,9 +1,10 @@
import React, { useEffect, useState } from "react";
import { HomepageAuth } from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
import { HomepageUpdateLog } from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx";
import { HomepageAlertManager } from "@/ui/Desktop/Homepage/HomepageAlertManager.tsx";
import { Auth } from "@/ui/Desktop/Authentication/Auth.tsx";
import { HomepageUpdateLog } from "@/ui/Desktop/Apps/Homepage/Apps/UpdateLog.tsx";
import { AlertManager } from "@/ui/Desktop/Apps/Homepage/Apps/Alerts/AlertManager.tsx";
import { Button } from "@/components/ui/button.tsx";
import { getUserInfo, getDatabaseHealth, getCookie } from "@/ui/main-axios.ts";
import { useSidebar } from "@/components/ui/sidebar.tsx";
interface HomepageProps {
onSelectView: (view: string) => void;
@@ -29,8 +30,14 @@ export function Homepage({
const [userId, setUserId] = useState<string | null>(null);
const [dbError, setDbError] = useState<string | null>(null);
let sidebarState: "expanded" | "collapsed" = "expanded";
try {
const sidebar = useSidebar();
sidebarState = sidebar.state;
} catch {}
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8;
useEffect(() => {
@@ -80,7 +87,7 @@ export function Homepage({
<>
{!loggedIn ? (
<div className="w-full h-full flex items-center justify-center">
<HomepageAuth
<Auth
setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin}
setUsername={setUsername}
@@ -94,7 +101,7 @@ export function Homepage({
</div>
) : (
<div
className="w-full h-full flex items-center justify-center"
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex items-center justify-center"
style={{
marginLeft: leftMarginPx,
marginRight: 17,
@@ -103,67 +110,62 @@ export function Homepage({
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
}}
>
<div className="flex flex-row items-center justify-center gap-8 relative z-10">
<div className="flex flex-col items-center gap-6 w-[400px]">
<HomepageUpdateLog loggedIn={loggedIn} />
<div className="flex flex-col items-center justify-center gap-6 relative z-10">
<HomepageUpdateLog loggedIn={loggedIn} />
<div className="flex flex-row items-center gap-3">
<Button
variant="outline"
size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() =>
window.open("https://github.com/Termix-SSH/Termix", "_blank")
}
>
GitHub
</Button>
<div className="w-px h-4 bg-dark-border"></div>
<Button
variant="outline"
size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() =>
window.open(
"https://github.com/Termix-SSH/Termix/issues/new",
"_blank",
)
}
>
Feedback
</Button>
<div className="w-px h-4 bg-dark-border"></div>
<Button
variant="outline"
size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() =>
window.open(
"https://discord.com/invite/jVQGdvHDrf",
"_blank",
)
}
>
Discord
</Button>
<div className="w-px h-4 bg-dark-border"></div>
<Button
variant="outline"
size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() =>
window.open("https://github.com/sponsors/LukeGus", "_blank")
}
>
Donate
</Button>
</div>
<div className="flex flex-row items-center gap-3 flex-wrap justify-center">
<Button
variant="outline"
size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() =>
window.open("https://github.com/Termix-SSH/Termix", "_blank")
}
>
GitHub
</Button>
<div className="w-px h-4 bg-dark-border"></div>
<Button
variant="outline"
size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() =>
window.open(
"https://github.com/Termix-SSH/Termix/issues/new",
"_blank",
)
}
>
Feedback
</Button>
<div className="w-px h-4 bg-dark-border"></div>
<Button
variant="outline"
size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() =>
window.open("https://discord.com/invite/jVQGdvHDrf", "_blank")
}
>
Discord
</Button>
<div className="w-px h-4 bg-dark-border"></div>
<Button
variant="outline"
size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() =>
window.open("https://github.com/sponsors/LukeGus", "_blank")
}
>
Donate
</Button>
</div>
</div>
</div>
)}
<HomepageAlertManager userId={userId} loggedIn={loggedIn} />
<AlertManager userId={userId} loggedIn={loggedIn} />
</>
);
}

View File

@@ -308,6 +308,7 @@ export function Server({
<Button
variant="outline"
disabled={isRefreshing}
className="font-semibold"
onClick={async () => {
if (currentHostConfig?.id) {
try {

View File

@@ -9,7 +9,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Plus, Play, Edit, Trash2, Copy } from "lucide-react";
import { Plus, Play, Edit, Trash2, Copy, X } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
@@ -177,7 +177,7 @@ export function SnippetsSidebar({
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title={t("common.close")}
>
<span className="text-lg font-bold leading-none">×</span>
<X />
</Button>
</div>

View File

@@ -374,7 +374,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
) {
ws.addEventListener("open", () => {
connectionTimeoutRef.current = setTimeout(() => {
if (!isConnected) {
if (!isConnected && !totpRequired) {
if (terminal) {
terminal.clear();
}
@@ -482,6 +482,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
} else if (msg.type === "totp_required") {
setTotpRequired(true);
setTotpPrompt(msg.prompt || "Verification code:");
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
}
} catch {
toast.error(t("terminal.messageParseError"));
@@ -788,6 +792,13 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
}}
/>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
/>
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-dark-bg">
<div className="flex items-center gap-3">
@@ -796,13 +807,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
</div>
</div>
)}
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
/>
</div>
);
},

View File

@@ -23,7 +23,7 @@ import {
isElectron,
logoutUser,
} from "../../main-axios.ts";
import { ServerConfig as ServerConfigComponent } from "@/ui/Desktop/Electron Only/ServerConfig.tsx";
import { ElectronServerConfig as ServerConfigComponent } from "@/ui/Desktop/Authentication/ElectronServerConfig.tsx";
interface HomepageAuthProps extends React.ComponentProps<"div"> {
setLoggedIn: (loggedIn: boolean) => void;
@@ -40,7 +40,7 @@ interface HomepageAuthProps extends React.ComponentProps<"div"> {
}) => void;
}
export function HomepageAuth({
export function Auth({
className,
setLoggedIn,
setIsAdmin,

View File

@@ -18,7 +18,7 @@ interface ServerConfigProps {
isFirstTime?: boolean;
}
export function ServerConfig({
export function ElectronServerConfig({
onServerConfigured,
onCancel,
isFirstTime = false,

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { LeftSidebar } from "@/ui/Desktop/Navigation/LeftSidebar.tsx";
import { Homepage } from "@/ui/Desktop/Homepage/Homepage.tsx";
import { Homepage } from "@/ui/Desktop/Apps/Homepage/Homepage.tsx";
import { AppView } from "@/ui/Desktop/Navigation/AppView.tsx";
import { HostManager } from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx";
import {

View File

@@ -1,182 +0,0 @@
import React, { useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
loggedIn: boolean;
}
interface ReleaseItem {
id: number;
title: string;
description: string;
link: string;
pubDate: string;
version: string;
isPrerelease: boolean;
isDraft: boolean;
assets: Array<{
name: string;
size: number;
download_count: number;
download_url: string;
}>;
}
interface RSSResponse {
feed: {
title: string;
description: string;
link: string;
updated: string;
};
items: ReleaseItem[];
total_count: number;
cached: boolean;
cache_age?: number;
}
interface VersionResponse {
status: "up_to_date" | "requires_update";
version: string;
latest_release: {
name: string;
published_at: string;
html_url: string;
};
cached: boolean;
cache_age?: number;
}
export function HomepageUpdateLog({ loggedIn }: HomepageUpdateLogProps) {
const { t } = useTranslation();
const [releases, setReleases] = useState<RSSResponse | null>(null);
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (loggedIn) {
setLoading(true);
Promise.all([getReleasesRSS(100), getVersionInfo()])
.then(([releasesRes, versionRes]) => {
setReleases(releasesRes);
setVersionInfo(versionRes);
setError(null);
})
.catch(() => {
setError(t("common.failedToFetchUpdateInfo"));
})
.finally(() => setLoading(false));
}
}, [loggedIn]);
if (!loggedIn) {
return null;
}
const formatDescription = (description: string) => {
const firstLine = description.split("\n")[0];
return firstLine.replace(/[#*`]/g, "").replace(/\s+/g, " ").trim();
};
return (
<div className="w-[400px] h-[600px] flex flex-col border-2 border-dark-border rounded-lg bg-dark-bg p-4 shadow-lg">
<div>
<h3 className="text-lg font-bold mb-3 text-white">
{t("common.updatesAndReleases")}
</h3>
<Separator className="p-0.25 mt-3 mb-3 bg-dark-border" />
{versionInfo && versionInfo.status === "requires_update" && (
<Alert className="bg-dark-bg-darker border-dark-border text-white">
<AlertTitle className="text-white">
{t("common.updateAvailable")}
</AlertTitle>
<AlertDescription className="text-gray-300">
{t("common.newVersionAvailable", {
version: versionInfo.version,
})}
</AlertDescription>
</Alert>
)}
</div>
{versionInfo && versionInfo.status === "requires_update" && (
<Separator className="p-0.25 mt-3 mb-3 bg-dark-border" />
)}
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
{loading && (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
)}
{error && (
<Alert
variant="destructive"
className="bg-red-900/20 border-red-500 text-red-300"
>
<AlertTitle className="text-red-300">
{t("common.error")}
</AlertTitle>
<AlertDescription className="text-red-300">
{error}
</AlertDescription>
</Alert>
)}
{releases?.items.map((release) => (
<div
key={release.id}
className="border border-dark-border rounded-lg p-3 hover:bg-dark-bg-darker transition-colors cursor-pointer bg-dark-bg-darker/50"
onClick={() => window.open(release.link, "_blank")}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-sm leading-tight flex-1 text-white">
{release.title}
</h4>
{release.isPrerelease && (
<span className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
{t("common.preRelease")}
</span>
)}
</div>
<p className="text-xs text-gray-300 mb-2 leading-relaxed">
{formatDescription(release.description)}
</p>
<div className="flex items-center text-xs text-gray-400">
<span>{new Date(release.pubDate).toLocaleDateString()}</span>
{release.assets.length > 0 && (
<>
<span className="mx-2"></span>
<span>
{release.assets.length} asset
{release.assets.length !== 1 ? "s" : ""}
</span>
</>
)}
</div>
</div>
))}
{releases && releases.items.length === 0 && !loading && (
<Alert className="bg-dark-bg-darker border-dark-border text-gray-300">
<AlertTitle className="text-gray-300">
{t("common.noReleases")}
</AlertTitle>
<AlertDescription className="text-gray-400">
{t("common.noReleasesFound")}
</AlertDescription>
</Alert>
)}
</div>
</div>
);
}

View File

@@ -23,8 +23,8 @@ export function TOTPDialog({
if (!isOpen) return null;
return (
<div className="fixed inset-0 flex items-center justify-center z-50">
<div className="absolute inset-0 bg-black/50" />
<div className="absolute inset-0 flex items-center justify-center z-50">
<div className="absolute inset-0 bg-dark-bg rounded-md" />
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md w-full mx-4 relative z-10">
<div className="mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-primary" />