From 6e5b34f87850b11131f7f807977e7ef77b8d8505 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 11 Aug 2025 02:08:17 -0500 Subject: [PATCH 01/65] Rename terminals, add welcome card for the survey, add buttons to make it clear how to open the sidebar/topbar after closing. --- .env | 2 +- src/App.tsx | 4 +- src/apps/Homepage/Homepage.tsx | 17 ++++++ src/apps/Homepage/HomepageWelcomeCard.tsx | 58 +++++++++++++++++++ .../SSH/Terminal/{SSH.tsx => Terminal.tsx} | 36 +++++++----- ...{SSHTerminal.tsx => TerminalComponent.tsx} | 2 +- .../{SSHSidebar.tsx => TerminalSidebar.tsx} | 2 +- .../{SSHTabList.tsx => TerminalTabList.tsx} | 2 +- .../{SSHTopbar.tsx => TerminalTopbar.tsx} | 6 +- 9 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 src/apps/Homepage/HomepageWelcomeCard.tsx rename src/apps/SSH/Terminal/{SSH.tsx => Terminal.tsx} (97%) rename src/apps/SSH/Terminal/{SSHTerminal.tsx => TerminalComponent.tsx} (99%) rename src/apps/SSH/Terminal/{SSHSidebar.tsx => TerminalSidebar.tsx} (99%) rename src/apps/SSH/Terminal/{SSHTabList.tsx => TerminalTabList.tsx} (98%) rename src/apps/SSH/Terminal/{SSHTopbar.tsx => TerminalTopbar.tsx} (94%) diff --git a/.env b/.env index 3967bc09..27929cde 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -VERSION=1.1 \ No newline at end of file +VERSION=1.1.1 \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 8940ef8c..c4510ae9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import React from "react" import {Homepage} from "@/apps/Homepage/Homepage.tsx" -import {SSH} from "@/apps/SSH/Terminal/SSH.tsx" +import {Terminal} from "@/apps/SSH/Terminal/Terminal.tsx" import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx"; import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx"; import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx" @@ -35,7 +35,7 @@ function App() { )} {mountedViews.has("terminal") && (
- +
)} {mountedViews.has("tunnel") && ( diff --git a/src/apps/Homepage/Homepage.tsx b/src/apps/Homepage/Homepage.tsx index 470168b5..9efe1e67 100644 --- a/src/apps/Homepage/Homepage.tsx +++ b/src/apps/Homepage/Homepage.tsx @@ -3,6 +3,7 @@ import React, {useEffect, useState} from "react"; import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx"; import axios from "axios"; import {HomepageUpdateLog} from "@/apps/Homepage/HompageUpdateLog.tsx"; +import {HomepageWelcomeCard} from "@/apps/Homepage/HomepageWelcomeCard.tsx"; interface HomepageProps { onSelectView: (view: string) => void; @@ -32,9 +33,12 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement { const [username, setUsername] = useState(null); const [authLoading, setAuthLoading] = useState(true); const [dbError, setDbError] = useState(null); + const [showWelcomeCard, setShowWelcomeCard] = useState(true); useEffect(() => { const jwt = getCookie("jwt"); + const welcomeHidden = getCookie("welcome_hidden"); + if (jwt) { setAuthLoading(true); Promise.all([ @@ -46,6 +50,7 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement { setIsAdmin(!!meRes.data.is_admin); setUsername(meRes.data.username || null); setDbError(null); + setShowWelcomeCard(welcomeHidden !== "true"); }) .catch((err) => { setLoggedIn(false); @@ -64,6 +69,11 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement { } }, []); + const handleHideWelcomeCard = () => { + setShowWelcomeCard(false); + setCookie("welcome_hidden", "true", 365 * 10); + }; + return ( + + {loggedIn && !authLoading && showWelcomeCard && ( +
+ +
+ )}
); diff --git a/src/apps/Homepage/HomepageWelcomeCard.tsx b/src/apps/Homepage/HomepageWelcomeCard.tsx new file mode 100644 index 00000000..4ef52ce3 --- /dev/null +++ b/src/apps/Homepage/HomepageWelcomeCard.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card"; +import {Button} from "@/components/ui/button"; + +interface HomepageWelcomeCardProps { + onHidePermanently: () => void; +} + +export function HomepageWelcomeCard({onHidePermanently}: HomepageWelcomeCardProps): React.ReactElement { + return ( + + + + The Future of Termix + + + +

+ Please checkout the linked survey{" "} + + here + + . The purpose of this survey is to gather feedback from users on what the future UI of Termix could + look like to optimize server management. Please take a minute or two to read the survey questions + and answer them to the best of your ability. Thank you! +

+

+ A special thanks to those in Asia who recently joined Termix through various forum posts, keep + sharing it! A Chinese translation is planned for Termix, but since I don’t speak Chinese, I’ll need + to hire someone to help with the translation. If you’d like to support me financially, you can do + so{" "} + + here. + +

+
+ + + +
+ ); +} diff --git a/src/apps/SSH/Terminal/SSH.tsx b/src/apps/SSH/Terminal/Terminal.tsx similarity index 97% rename from src/apps/SSH/Terminal/SSH.tsx rename to src/apps/SSH/Terminal/Terminal.tsx index 002be4ca..e6e92600 100644 --- a/src/apps/SSH/Terminal/SSH.tsx +++ b/src/apps/SSH/Terminal/Terminal.tsx @@ -1,9 +1,10 @@ import React, {useState, useRef, useEffect} from "react"; -import {SSHSidebar} from "@/apps/SSH/Terminal/SSHSidebar.tsx"; -import {SSHTerminal} from "./SSHTerminal.tsx"; -import {SSHTopbar} from "@/apps/SSH/Terminal/SSHTopbar.tsx"; +import {TerminalSidebar} from "@/apps/SSH/Terminal/TerminalSidebar.tsx"; +import {TerminalComponent} from "./TerminalComponent.tsx"; +import {TerminalTopbar} from "@/apps/SSH/Terminal/TerminalTopbar.tsx"; import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx'; import * as ResizablePrimitive from "react-resizable-panels"; +import {ChevronDown, ChevronRight} from "lucide-react"; interface ConfigEditorProps { onSelectView: (view: string) => void; @@ -16,7 +17,7 @@ type Tab = { terminalRef: React.RefObject; }; -export function SSH({onSelectView}: ConfigEditorProps): React.ReactElement { +export function Terminal({onSelectView}: ConfigEditorProps): React.ReactElement { const [allTabs, setAllTabs] = useState([]); const [currentTab, setCurrentTab] = useState(null); const [allSplitScreenTab, setAllSplitScreenTab] = useState([]); @@ -25,7 +26,7 @@ export function SSH({onSelectView}: ConfigEditorProps): React.ReactElement { const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [isTopbarOpen, setIsTopbarOpen] = useState(true); const SIDEBAR_WIDTH = 256; - const HANDLE_THICKNESS = 6; + const HANDLE_THICKNESS = 10; const [panelRects, setPanelRects] = useState>({}); const panelRefs = useRef>({}); @@ -160,7 +161,7 @@ export function SSH({onSelectView}: ConfigEditorProps): React.ReactElement { const isVisible = !!layoutStyles[tab.id]; return (
- - {/* Sidebar (collapsible) */}
- - + title="Show top bar"> + +
)} - {/* Main terminal area (height adapts to topbar) */}
- {/* Sidebar reopen handle */} {!isSidebarOpen && (
setIsSidebarOpen(true)} @@ -769,9 +771,13 @@ export function SSH({onSelectView}: ConfigEditorProps): React.ReactElement { background: '#222224', cursor: 'pointer', zIndex: 20, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', }} - title="Show sidebar" - /> + title="Show sidebar"> + +
)}
); diff --git a/src/apps/SSH/Terminal/SSHTerminal.tsx b/src/apps/SSH/Terminal/TerminalComponent.tsx similarity index 99% rename from src/apps/SSH/Terminal/SSHTerminal.tsx rename to src/apps/SSH/Terminal/TerminalComponent.tsx index 74089981..c69ac609 100644 --- a/src/apps/SSH/Terminal/SSHTerminal.tsx +++ b/src/apps/SSH/Terminal/TerminalComponent.tsx @@ -13,7 +13,7 @@ interface SSHTerminalProps { splitScreen?: boolean; } -export const SSHTerminal = forwardRef(function SSHTerminal( +export const TerminalComponent = forwardRef(function SSHTerminal( {hostConfig, isVisible, splitScreen = false}, ref ) { diff --git a/src/apps/SSH/Terminal/SSHSidebar.tsx b/src/apps/SSH/Terminal/TerminalSidebar.tsx similarity index 99% rename from src/apps/SSH/Terminal/SSHSidebar.tsx rename to src/apps/SSH/Terminal/TerminalSidebar.tsx index e92dbae5..90318520 100644 --- a/src/apps/SSH/Terminal/SSHSidebar.tsx +++ b/src/apps/SSH/Terminal/TerminalSidebar.tsx @@ -74,7 +74,7 @@ export interface SidebarProps { onOpenChange?: (open: boolean) => void; } -export function SSHSidebar({ +export function TerminalSidebar({ onSelectView, onHostConnect, allTabs, diff --git a/src/apps/SSH/Terminal/SSHTabList.tsx b/src/apps/SSH/Terminal/TerminalTabList.tsx similarity index 98% rename from src/apps/SSH/Terminal/SSHTabList.tsx rename to src/apps/SSH/Terminal/TerminalTabList.tsx index b60bfbb5..2e52d865 100644 --- a/src/apps/SSH/Terminal/SSHTabList.tsx +++ b/src/apps/SSH/Terminal/TerminalTabList.tsx @@ -16,7 +16,7 @@ interface SSHTabListProps { setCloseTab: (tab: number) => void; } -export function SSHTabList({ +export function TerminalTabList({ allTabs, currentTab, setActiveTab, diff --git a/src/apps/SSH/Terminal/SSHTopbar.tsx b/src/apps/SSH/Terminal/TerminalTopbar.tsx similarity index 94% rename from src/apps/SSH/Terminal/SSHTopbar.tsx rename to src/apps/SSH/Terminal/TerminalTopbar.tsx index 30d4c910..036ae622 100644 --- a/src/apps/SSH/Terminal/SSHTopbar.tsx +++ b/src/apps/SSH/Terminal/TerminalTopbar.tsx @@ -1,4 +1,4 @@ -import {SSHTabList} from "@/apps/SSH/Terminal/SSHTabList.tsx"; +import {TerminalTabList} from "@/apps/SSH/Terminal/TerminalTabList.tsx"; import React from "react"; import {ChevronUp} from "lucide-react"; @@ -17,7 +17,7 @@ interface SSHTopbarProps { onHideTopbar?: () => void; } -export function SSHTopbar({ +export function TerminalTopbar({ allTabs, currentTab, setActiveTab, @@ -38,7 +38,7 @@ export function SSHTopbar({ }}>
- Date: Mon, 11 Aug 2025 22:10:40 +0200 Subject: [PATCH 02/65] dependabot.yml erstellen --- .github/dependabot.yml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..4ba3c6d5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,40 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + groups: + dev-patch-updates: + dependency-type: "development" + update-types: + - "patch" + dev-minor-updates: + dependency-type: "development" + update-types: + - "minor" + prod-patch-updates: + dependency-type: "production" + update-types: + - "patch" + prod-minor-updates: + dependency-type: "production" + update-types: + - "minor" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "daily" + groups: + patch-updates: + update-types: + - "patch" + minor-updates: + update-types: + - "minor" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file From a2481362a299b39b6622e8b4102cc16519c33428 Mon Sep 17 00:00:00 2001 From: Marvin <127591405+Lokowitz@users.noreply.github.com> Date: Mon, 11 Aug 2025 22:31:57 +0200 Subject: [PATCH 03/65] dependabot.yml aktualisieren --- .github/dependabot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4ba3c6d5..89762628 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,7 +23,7 @@ updates: - "minor" - package-ecosystem: "docker" - directory: "/" + directory: "/docker" schedule: interval: "daily" groups: From 1f83fb68f04fadc4aa71b3d3288987fa7872eab3 Mon Sep 17 00:00:00 2001 From: Marvin <127591405+Lokowitz@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:04:04 +0200 Subject: [PATCH 04/65] Update Dockerfile --- docker/Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 463d8ed9..0e88425f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ # Stage 1: Install dependencies and build frontend -FROM node:18-alpine AS deps +FROM node:22-alpine AS deps WORKDIR /app RUN apk add --no-cache python3 make g++ @@ -26,7 +26,7 @@ COPY . . RUN npm run build:backend # Stage 4: Production dependencies -FROM node:18-alpine AS production-deps +FROM node:22-alpine AS production-deps WORKDIR /app COPY package*.json ./ @@ -35,7 +35,7 @@ RUN npm ci --only=production --ignore-scripts --force && \ npm cache clean --force # Stage 5: Build native modules -FROM node:18-alpine AS native-builder +FROM node:22-alpine AS native-builder WORKDIR /app RUN apk add --no-cache python3 make g++ @@ -46,7 +46,7 @@ RUN npm ci --only=production bcryptjs better-sqlite3 --force && \ npm cache clean --force # Stage 6: Final image -FROM node:18-alpine +FROM node:22-alpine ENV DATA_DIR=/app/data \ PORT=8080 \ NODE_ENV=production @@ -76,4 +76,4 @@ EXPOSE ${PORT} 8081 8082 8083 8084 COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -CMD ["/entrypoint.sh"] \ No newline at end of file +CMD ["/entrypoint.sh"] From 3b347f7ae5c8f69453d39e92ee2effefac1f18f3 Mon Sep 17 00:00:00 2001 From: Marvin <127591405+Lokowitz@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:04:47 +0200 Subject: [PATCH 05/65] Create .nvmrc --- .nvmrc | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 From 602f21b475b6c1e194eb0412ae2d23198ea3e451 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 12 Aug 2025 13:15:08 -0500 Subject: [PATCH 06/65] Confirm and hide password, reset password, delete accounts, better admin page, json import hosts. --- README.md | 1 + src/apps/Homepage/HomepageAuth.tsx | 351 ++++++++- src/apps/Homepage/HomepageSidebar.tsx | 744 ++++++++++++++---- src/apps/SSH/Manager/SSHManagerHostViewer.tsx | 412 +++++++++- src/apps/SSH/ssh-axios.ts | 14 + src/backend/database/routes/ssh.ts | 118 ++- src/backend/database/routes/users.ts | 445 +++++++++-- src/components/ui/table.tsx | 114 +++ src/components/ui/tooltip.tsx | 1 - 9 files changed, 1957 insertions(+), 243 deletions(-) create mode 100644 src/components/ui/table.tsx diff --git a/README.md b/README.md index 64243c8f..fbdb385c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Termix is an open-source, forever-free, self-hosted all-in-one server management - **Theming** - Modify themeing for all tools - **Improved SFTP Support** - Ability to manage files easier with the config editor by uploading, creating, and removing files - **Improved Terminal Support** - Add more terminal protocols such as VNC and RDP (anyone who has experience in integrating RDP into a web-application similar to Apache Guacamole, please contact me by creating an issue) +- **Mobile Support** - Support a mobile app or version of the Termix website to manage servers from your phone # Installation Visit the Termix [Docs](https://docs.termix.site/docs) for more information on how to install Termix. Otherwise, view a sample docker-compose file here: diff --git a/src/apps/Homepage/HomepageAuth.tsx b/src/apps/Homepage/HomepageAuth.tsx index 3ad1b24a..30d3a7ea 100644 --- a/src/apps/Homepage/HomepageAuth.tsx +++ b/src/apps/Homepage/HomepageAuth.tsx @@ -46,9 +46,10 @@ export function HomepageAuth({ setDbError, ...props }: HomepageAuthProps) { - const [tab, setTab] = useState<"login" | "signup" | "external">("login"); + const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login"); const [localUsername, setLocalUsername] = useState(""); const [password, setPassword] = useState(""); + const [signupConfirmPassword, setSignupConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false); const [error, setError] = useState(null); @@ -57,6 +58,14 @@ export function HomepageAuth({ const [registrationAllowed, setRegistrationAllowed] = useState(true); const [oidcConfigured, setOidcConfigured] = useState(false); + const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate"); + const [resetCode, setResetCode] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [tempToken, setTempToken] = useState(""); + const [resetLoading, setResetLoading] = useState(false); + const [resetSuccess, setResetSuccess] = useState(false); + useEffect(() => { setInternalLoggedIn(loggedIn); }, [loggedIn]); @@ -101,11 +110,28 @@ export function HomepageAuth({ e.preventDefault(); setError(null); setLoading(true); + + if (!localUsername.trim()) { + setError("Username is required"); + setLoading(false); + return; + } + try { let res, meRes; if (tab === "login") { res = await API.post("/login", {username: localUsername, password}); } else { + if (password !== signupConfirmPassword) { + setError("Passwords do not match"); + setLoading(false); + return; + } + if (password.length < 6) { + setError("Password must be at least 6 characters long"); + setLoading(false); + return; + } await API.post("/create", {username: localUsername, password}); res = await API.post("/login", {username: localUsername, password}); } @@ -119,6 +145,9 @@ export function HomepageAuth({ setIsAdmin(!!meRes.data.is_admin); setUsername(meRes.data.username || null); setDbError(null); + if (tab === "signup") { + setSignupConfirmPassword(""); + } } catch (err: any) { setError(err?.response?.data?.error || "Unknown error"); setInternalLoggedIn(false); @@ -136,6 +165,97 @@ export function HomepageAuth({ } } + async function initiatePasswordReset() { + setError(null); + setResetLoading(true); + try { + await API.post("/initiate-reset", {username: localUsername}); + setResetStep("verify"); + setError(null); + } catch (err: any) { + setError(err?.response?.data?.error || "Failed to initiate password reset"); + } finally { + setResetLoading(false); + } + } + + async function verifyResetCode() { + setError(null); + setResetLoading(true); + try { + const response = await API.post("/verify-reset-code", { + username: localUsername, + resetCode: resetCode + }); + setTempToken(response.data.tempToken); + setResetStep("newPassword"); + setError(null); + } catch (err: any) { + setError(err?.response?.data?.error || "Failed to verify reset code"); + } finally { + setResetLoading(false); + } + } + + async function completePasswordReset() { + setError(null); + setResetLoading(true); + + if (newPassword !== confirmPassword) { + setError("Passwords do not match"); + setResetLoading(false); + return; + } + + if (newPassword.length < 6) { + setError("Password must be at least 6 characters long"); + setResetLoading(false); + return; + } + + try { + await API.post("/complete-reset", { + username: localUsername, + tempToken: tempToken, + newPassword: newPassword + }); + + setResetStep("initiate"); + setResetCode(""); + setNewPassword(""); + setConfirmPassword(""); + setTempToken(""); + setError(null); + + setResetSuccess(true); + } catch (err: any) { + setError(err?.response?.data?.error || "Failed to complete password reset"); + } finally { + setResetLoading(false); + } + } + + function resetPasswordState() { + setResetStep("initiate"); + setResetCode(""); + setNewPassword(""); + setConfirmPassword(""); + setTempToken(""); + setError(null); + setResetSuccess(false); + setSignupConfirmPassword(""); + } + + function clearFormFields() { + setPassword(""); + setSignupConfirmPassword(""); + setError(null); + } + + async function resetPassword() { + + } + async function handleOIDCLogin() { setError(null); setOidcLoading(true); @@ -301,7 +421,11 @@ export function HomepageAuth({ ? "bg-primary text-primary-foreground shadow" : "bg-muted text-muted-foreground hover:bg-accent" )} - onClick={() => setTab("login")} + onClick={() => { + setTab("login"); + if (tab === "reset") resetPasswordState(); + if (tab === "signup") clearFormFields(); + }} aria-selected={tab === "login"} disabled={loading || firstUser} > @@ -315,7 +439,11 @@ export function HomepageAuth({ ? "bg-primary text-primary-foreground shadow" : "bg-muted text-muted-foreground hover:bg-accent" )} - onClick={() => setTab("signup")} + onClick={() => { + setTab("signup"); + if (tab === "reset") resetPasswordState(); + if (tab === "login") clearFormFields(); + }} aria-selected={tab === "signup"} disabled={loading || !registrationAllowed} > @@ -330,7 +458,11 @@ export function HomepageAuth({ ? "bg-primary text-primary-foreground shadow" : "bg-muted text-muted-foreground hover:bg-accent" )} - onClick={() => setTab("external")} + onClick={() => { + setTab("external"); + if (tab === "reset") resetPasswordState(); + if (tab === "login" || tab === "signup") clearFormFields(); + }} aria-selected={tab === "external"} disabled={oidcLoading} > @@ -342,23 +474,185 @@ export function HomepageAuth({

{tab === "login" ? "Login to your account" : tab === "signup" ? "Create a new account" : - "Login with external provider"} + tab === "external" ? "Login with external provider" : + "Reset your password"}

- {tab === "external" ? ( + {tab === "external" || tab === "reset" ? (
-
-

Login using your configured external identity provider

-
- + {tab === "external" && ( + <> +
+

Login using your configured external identity provider

+
+ + + )} + {tab === "reset" && ( + <> + {resetStep === "initiate" && ( + <> +
+

Enter your username to receive a password reset code. The code + will be logged in the docker container logs.

+
+
+
+ + setLocalUsername(e.target.value)} + disabled={resetLoading} + /> +
+ +
+ + )} + + {resetStep === "verify" && ( + <> +
+

Enter the 6-digit code from the docker container logs for + user: {localUsername}

+
+
+
+ + setResetCode(e.target.value.replace(/\D/g, ''))} + disabled={resetLoading} + placeholder="000000" + /> +
+ + +
+ + )} + + {resetSuccess && ( + <> + + Success! + + Your password has been successfully reset! You can now log in + with your new password. + + + + + )} + + {resetStep === "newPassword" && !resetSuccess && ( + <> +
+

Enter your new password for + user: {localUsername}

+
+
+
+ + setNewPassword(e.target.value)} + disabled={resetLoading} + /> +
+
+ + setConfirmPassword(e.target.value)} + disabled={resetLoading} + /> +
+ + +
+ + )} + + )}
) : (
@@ -380,10 +674,33 @@ export function HomepageAuth({ value={password} onChange={e => setPassword(e.target.value)} disabled={loading || internalLoggedIn}/>
+ {tab === "signup" && ( +
+ + setSignupConfirmPassword(e.target.value)} + disabled={loading || internalLoggedIn}/> +
+ )} + {tab === "login" && ( + + )} )} diff --git a/src/apps/Homepage/HomepageSidebar.tsx b/src/apps/Homepage/HomepageSidebar.tsx index ec2af144..ec0df53c 100644 --- a/src/apps/Homepage/HomepageSidebar.tsx +++ b/src/apps/Homepage/HomepageSidebar.tsx @@ -3,7 +3,7 @@ import { Computer, Server, File, - Hammer, ChevronUp, User2, HardDrive + Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings } from "lucide-react"; import { @@ -36,6 +36,15 @@ import {Input} from "@/components/ui/input.tsx"; import {Label} from "@/components/ui/label.tsx"; import {Button} from "@/components/ui/button.tsx"; import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx"; +import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx"; import axios from "axios"; interface SidebarProps { @@ -90,6 +99,24 @@ export function HomepageSidebar({ const [oidcError, setOidcError] = React.useState(null); const [oidcSuccess, setOidcSuccess] = React.useState(null); + const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false); + const [deletePassword, setDeletePassword] = React.useState(""); + const [deleteLoading, setDeleteLoading] = React.useState(false); + const [deleteError, setDeleteError] = React.useState(null); + const [adminCount, setAdminCount] = React.useState(0); + + const [users, setUsers] = React.useState>([]); + const [usersLoading, setUsersLoading] = React.useState(false); + const [newAdminUsername, setNewAdminUsername] = React.useState(""); + const [makeAdminLoading, setMakeAdminLoading] = React.useState(false); + const [makeAdminError, setMakeAdminError] = React.useState(null); + const [makeAdminSuccess, setMakeAdminSuccess] = React.useState(null); + React.useEffect(() => { if (adminSheetOpen) { API.get("/registration-allowed").then(res => { @@ -102,6 +129,9 @@ export function HomepageSidebar({ } }).catch((error) => { }); + fetchUsers(); + } else { + fetchAdminCount(); } }, [adminSheetOpen]); @@ -129,13 +159,13 @@ export function HomepageSidebar({ const requiredFields = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url']; const missingFields = requiredFields.filter(field => !oidcConfig[field as keyof typeof oidcConfig]); - + if (missingFields.length > 0) { setOidcError(`Missing required fields: ${missingFields.join(', ')}`); setOidcLoading(false); return; } - + const jwt = getCookie("jwt"); try { await API.post( @@ -158,6 +188,116 @@ export function HomepageSidebar({ })); }; + const handleDeleteAccount = async (e: React.FormEvent) => { + e.preventDefault(); + setDeleteLoading(true); + setDeleteError(null); + + if (!deletePassword.trim()) { + setDeleteError("Password is required"); + setDeleteLoading(false); + return; + } + + const jwt = getCookie("jwt"); + try { + await API.delete("/delete-account", { + headers: {Authorization: `Bearer ${jwt}`}, + data: {password: deletePassword} + }); + + handleLogout(); + } catch (err: any) { + setDeleteError(err?.response?.data?.error || "Failed to delete account"); + setDeleteLoading(false); + } + }; + + const fetchUsers = async () => { + setUsersLoading(true); + const jwt = getCookie("jwt"); + try { + const response = await API.get("/list", { + headers: {Authorization: `Bearer ${jwt}`} + }); + setUsers(response.data.users); + + const adminUsers = response.data.users.filter((user: any) => user.is_admin); + setAdminCount(adminUsers.length); + } catch (err: any) { + console.error("Failed to fetch users:", err); + } finally { + setUsersLoading(false); + } + }; + + const fetchAdminCount = async () => { + const jwt = getCookie("jwt"); + try { + const response = await API.get("/list", { + headers: {Authorization: `Bearer ${jwt}`} + }); + const adminUsers = response.data.users.filter((user: any) => user.is_admin); + setAdminCount(adminUsers.length); + } catch (err: any) { + console.error("Failed to fetch admin count:", err); + } + }; + + const makeUserAdmin = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newAdminUsername.trim()) return; + + setMakeAdminLoading(true); + setMakeAdminError(null); + setMakeAdminSuccess(null); + + const jwt = getCookie("jwt"); + try { + await API.post("/make-admin", + {username: newAdminUsername.trim()}, + {headers: {Authorization: `Bearer ${jwt}`}} + ); + setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`); + setNewAdminUsername(""); + fetchUsers(); + } catch (err: any) { + setMakeAdminError(err?.response?.data?.error || "Failed to make user admin"); + } finally { + setMakeAdminLoading(false); + } + }; + + const removeAdminStatus = async (username: string) => { + if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return; + + const jwt = getCookie("jwt"); + try { + await API.post("/remove-admin", + {username}, + {headers: {Authorization: `Bearer ${jwt}`}} + ); + fetchUsers(); + } catch (err: any) { + console.error("Failed to remove admin status:", err); + } + }; + + const deleteUser = async (username: string) => { + if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return; + + const jwt = getCookie("jwt"); + try { + await API.delete("/delete-user", { + headers: {Authorization: `Bearer ${jwt}`}, + data: {username} + }); + fetchUsers(); + } catch (err: any) { + console.error("Failed to delete user:", err); + } + }; + return (
@@ -201,7 +341,8 @@ export function HomepageSidebar({
- window.open("https://dashix.dev", "_blank")} disabled={disabled}> + window.open("https://dashix.dev", "_blank")} + disabled={disabled}> Tools @@ -243,6 +384,17 @@ export function HomepageSidebar({ onSelect={handleLogout}> Sign out + setDeleteAccountOpen(true)} + disabled={isAdmin && adminCount <= 1} + > + + Delete Account + {isAdmin && adminCount <= 1 && " (Last Admin)"} + + @@ -251,175 +403,357 @@ export function HomepageSidebar({ {/* Admin Settings Sheet (always rendered, only openable if isAdmin) */} {isAdmin && ( - - + + Admin Settings -
- {/* Registration Settings */} -
-

User Registration

- -
- - - {/* OIDC Configuration */} -
-

External Authentication (OIDC)

-

- Configure external identity provider for OIDC/OAuth2 authentication. - Users will see an "External" login option once configured. -

- - {oidcError && ( - - Error - {oidcError} - - )} - -
-
- - handleOIDCConfigChange('client_id', e.target.value)} - placeholder="your-client-id" - required - /> -
- -
- - handleOIDCConfigChange('client_secret', e.target.value)} - placeholder="your-client-secret" - required - /> -
+
+ + + + + Reg + + + + OIDC + + + + Users + + + + Admins + + -
- - handleOIDCConfigChange('authorization_url', e.target.value)} - placeholder="https://your-provider.com/application/o/authorize/" - required - /> -
- -
- - handleOIDCConfigChange('issuer_url', e.target.value)} - placeholder="https://your-provider.com/application/o/termix/" - required - /> -
- -
- - handleOIDCConfigChange('token_url', e.target.value)} - placeholder="http://100.98.3.50:9000/application/o/token/" - required - /> -
- -
- - handleOIDCConfigChange('identifier_path', e.target.value)} - placeholder="sub" - required - /> -

- JSON path to extract user ID from JWT (e.g., "sub", "email", "preferred_username") -

-
- -
- - handleOIDCConfigChange('name_path', e.target.value)} - placeholder="name" - required - /> -

- JSON path to extract display name from JWT (e.g., "name", "preferred_username") -

-
- -
- - handleOIDCConfigChange('scopes', e.target.value)} - placeholder="openid email profile" - required - /> -

- Space-separated list of OAuth2 scopes to request -

-
- -
- - + {/* Registration Settings Tab */} + +
+

User Registration

+
+
- {oidcSuccess && ( - - Success - {oidcSuccess} - - )} - -
+ {/* OIDC Configuration Tab */} + +
+

External Authentication + (OIDC)

+

+ Configure external identity provider for OIDC/OAuth2 authentication. + Users will see an "External" login option once configured. +

+ + {oidcError && ( + + Error + {oidcError} + + )} + +
+
+ + handleOIDCConfigChange('client_id', e.target.value)} + placeholder="your-client-id" + required + /> +
+ +
+ + handleOIDCConfigChange('client_secret', e.target.value)} + placeholder="your-client-secret" + required + /> +
+ +
+ + handleOIDCConfigChange('authorization_url', e.target.value)} + placeholder="https://your-provider.com/application/o/authorize/" + required + /> +
+ +
+ + handleOIDCConfigChange('issuer_url', e.target.value)} + placeholder="https://your-provider.com/application/o/termix/" + required + /> +
+ +
+ + handleOIDCConfigChange('token_url', e.target.value)} + placeholder="http://100.98.3.50:9000/application/o/token/" + required + /> +
+ +
+ + handleOIDCConfigChange('identifier_path', e.target.value)} + placeholder="sub" + required + /> +

+ JSON path to extract user ID from JWT (e.g., "sub", "email", + "preferred_username") +

+
+ +
+ + handleOIDCConfigChange('name_path', e.target.value)} + placeholder="name" + required + /> +

+ JSON path to extract display name from JWT (e.g., "name", + "preferred_username") +

+
+ +
+ + handleOIDCConfigChange('scopes', e.target.value)} + placeholder="openid email profile" + required + /> +

+ Space-separated list of OAuth2 scopes to request +

+
+ +
+ + +
+ + {oidcSuccess && ( + + Success + {oidcSuccess} + + )} +
+
+
+ + {/* Users Management Tab */} + +
+
+

User Management

+ +
+ + {usersLoading ? ( +
+ Loading users... +
+ ) : ( +
+ + + + Username + Type + Actions + + + + {users.map((user) => ( + + + {user.username} + {user.is_admin && ( + + Admin + + )} + + + {user.is_oidc ? "External" : "Local"} + + + + + + ))} + +
+
+ )} +
+
+ + {/* Admins Management Tab */} + +
+

Admin Management

+ + {/* Add New Admin Form */} +
+

Make User Admin

+
+
+ +
+ setNewAdminUsername(e.target.value)} + placeholder="Enter username to make admin" + required + /> + +
+
+ + {makeAdminError && ( + + Error + {makeAdminError} + + )} + + {makeAdminSuccess && ( + + Success + {makeAdminSuccess} + + )} +
+
+ + {/* Current Admins Table */} +
+

Current Admins

+
+ + + + Username + Type + Actions + + + + {users.filter(user => user.is_admin).map((admin) => ( + + + {admin.username} + + Admin + + + + {admin.is_oidc ? "External" : "Local"} + + + + + + ))} + +
+
+
+
+
+
- + + @@ -428,6 +762,84 @@ export function HomepageSidebar({ )} + + {/* Delete Account Confirmation Sheet */} + + + + Delete Account + + This action cannot be undone. This will permanently delete your account and all + associated data. + + +
+ + Warning + + Deleting your account will remove all your data including SSH hosts, + configurations, and settings. + This action is irreversible. + + + + {deleteError && ( + + Error + {deleteError} + + )} + +
+ {isAdmin && adminCount <= 1 && ( + + Cannot Delete Account + + You are the last admin user. You cannot delete your account as this + would leave the system without any administrators. + Please make another user an admin first, or contact system support. + + + )} + +
+ + setDeletePassword(e.target.value)} + placeholder="Enter your password to confirm" + required + disabled={isAdmin && adminCount <= 1} + /> +
+ +
+ + +
+
+
+
+
{children} diff --git a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx index 7fa5e714..e94a8d17 100644 --- a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx +++ b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx @@ -5,8 +5,9 @@ import {Badge} from "@/components/ui/badge"; import {ScrollArea} from "@/components/ui/scroll-area"; import {Input} from "@/components/ui/input"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion"; -import {getSSHHosts, deleteSSHHost} from "@/apps/SSH/ssh-axios"; -import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search} from "lucide-react"; +import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; +import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/apps/SSH/ssh-axios"; +import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search, Upload, Info} from "lucide-react"; interface SSHHost { id: number; @@ -36,6 +37,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); + const [importing, setImporting] = useState(false); useEffect(() => { fetchHosts(); @@ -71,6 +73,47 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { } }; + const handleJsonImport = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + setImporting(true); + const text = await file.text(); + const data = JSON.parse(text); + + if (!Array.isArray(data.hosts) && !Array.isArray(data)) { + throw new Error('JSON must contain a "hosts" array or be an array of hosts'); + } + + const hostsArray = Array.isArray(data.hosts) ? data.hosts : data; + + if (hostsArray.length === 0) { + throw new Error('No hosts found in JSON file'); + } + + if (hostsArray.length > 100) { + throw new Error('Maximum 100 hosts allowed per import'); + } + + const result = await bulkImportSSHHosts(hostsArray); + + if (result.success > 0) { + alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`); + await fetchHosts(); + } else { + alert(`Import failed: ${result.errors.join('\n')}`); + } + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file'; + alert(`Import error: ${errorMessage}`); + } finally { + setImporting(false); + event.target.value = ''; + } + }; + const filteredAndSortedHosts = useMemo(() => { let filtered = hosts; @@ -172,11 +215,370 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { {filteredAndSortedHosts.length} hosts

- +
+ + + + + + +
+

Import SSH Hosts from JSON

+

+ Upload a JSON file to bulk import multiple SSH hosts (max 100). +

+
+
+
+
+ + + + +
+
+ port - SSH port (number, 1-65535) + +
+
+ username - SSH username (string) + +
+
+ authType - "password" or "key" + +
+
+ +

Authentication Fields

+
+
+ password - Required if authType is "password" + +
+
+ key - SSH private key content (string) if authType is "key" + +
+
+ keyPassword - Optional key passphrase + +
+
+ keyType - Key type (auto, ssh-rsa, ssh-ed25519, etc.) + +
+
+ +

Optional Fields

+
+
+ name - Display name (string) + +
+
+ folder - Organization folder (string) + +
+
+ tags - Array of tag strings + +
+
+ pin - Pin to top (boolean) + +
+
+ enableTerminal - Show in Terminal tab (boolean, default: true) + +
+
+ enableTunnel - Show in Tunnel tab (boolean, default: true) + +
+
+ enableConfigEditor - Show in Config Editor tab (boolean, default: true) + +
+
+ defaultPath - Default directory path (string) + +
+
+ +

Tunnel Configuration

+
+
+ tunnelConnections - Array of tunnel objects + +
+
+
+ sourcePort - Local port (number) + +
+
+ endpointPort - Remote port (number) + +
+
+ endpointHost - Target host name (string) + +
+
+ maxRetries - Retry attempts (number, default: 3) + +
+
+ retryInterval - Retry delay in seconds (number, default: 10) + +
+
+ autoStart - Auto-start on launch (boolean, default: false) + +
+
+
+ +

Example JSON Structure

+
{
+  "hosts": [
+    {
+      "name": "Web Server",
+      "ip": "192.168.1.100",
+      "port": 22,
+      "username": "admin",
+      "authType": "password",
+      "password": "your_password",
+      "folder": "Production",
+      "tags": ["web", "production"],
+      "pin": true,
+      "enableTerminal": true,
+      "enableTunnel": false,
+      "enableConfigEditor": true,
+      "defaultPath": "/var/www"
+    }
+  ]
+}
+ +

Important Notes

+
    +
  • Maximum 100 hosts per import
  • +
  • File should contain a "hosts" array or be an array of host objects
  • +
  • All fields are copyable for easy reference
  • +
  • Use the Download Sample button to get a complete example file
  • +
+ + + `); + newWindow.document.close(); + } + }} + > + Format Guide + + + + + +
{ + try { + const response = await sshHostApi.post('/ssh/bulk-import', {hosts}); + return response.data; + } catch (error) { + throw error; + } +} + export async function deleteSSHHost(hostId: number): Promise { try { const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`); diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 841b3799..819af76a 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -94,7 +94,6 @@ router.get('/db/host/internal', async (req: Request, res: Response) => { } try { const data = await db.select().from(sshData); - // Convert tags to array, booleans to bool, tunnelConnections to array const result = data.map((row: any) => ({ ...row, tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [], @@ -116,9 +115,7 @@ router.get('/db/host/internal', async (req: Request, res: Response) => { router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { let hostData: any; - // Check if this is a multipart form data request (file upload) if (req.headers['content-type']?.includes('multipart/form-data')) { - // Parse the JSON data from the 'data' field if (req.body.data) { try { hostData = JSON.parse(req.body.data); @@ -131,12 +128,10 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque return res.status(400).json({error: 'Missing data field'}); } - // Add the file data if present if (req.file) { hostData.key = req.file.buffer.toString('utf8'); } } else { - // Regular JSON request hostData = req.body; } @@ -697,4 +692,117 @@ router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, } }); +// Route: Bulk import SSH hosts from JSON (requires JWT) +// POST /ssh/bulk-import +router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const {hosts} = req.body; + + if (!Array.isArray(hosts) || hosts.length === 0) { + logger.warn('Invalid bulk import data - hosts array is required and must not be empty'); + return res.status(400).json({error: 'Hosts array is required and must not be empty'}); + } + + if (hosts.length > 100) { + logger.warn(`Bulk import attempted with too many hosts: ${hosts.length}`); + return res.status(400).json({error: 'Maximum 100 hosts allowed per import'}); + } + + const results = { + success: 0, + failed: 0, + errors: [] as string[] + }; + + for (let i = 0; i < hosts.length; i++) { + const hostData = hosts[i]; + + try { + if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) { + results.failed++; + results.errors.push(`Host ${i + 1}: Missing or invalid required fields (ip, port, username)`); + continue; + } + + if (hostData.authType !== 'password' && hostData.authType !== 'key') { + results.failed++; + results.errors.push(`Host ${i + 1}: Invalid authType. Must be 'password' or 'key'`); + continue; + } + + if (hostData.authType === 'password' && !isNonEmptyString(hostData.password)) { + results.failed++; + results.errors.push(`Host ${i + 1}: Password required for password authentication`); + continue; + } + + if (hostData.authType === 'key' && !isNonEmptyString(hostData.key)) { + results.failed++; + results.errors.push(`Host ${i + 1}: SSH key required for key authentication`); + continue; + } + + // Validate tunnel connections if enabled + if (hostData.enableTunnel && Array.isArray(hostData.tunnelConnections)) { + for (let j = 0; j < hostData.tunnelConnections.length; j++) { + const conn = hostData.tunnelConnections[j]; + if (!isValidPort(conn.sourcePort) || !isValidPort(conn.endpointPort) || !isNonEmptyString(conn.endpointHost)) { + results.failed++; + results.errors.push(`Host ${i + 1}, Tunnel ${j + 1}: Invalid tunnel connection data`); + break; + } + } + } + + const sshDataObj: any = { + userId: userId, + name: hostData.name || '', + folder: hostData.folder || '', + tags: Array.isArray(hostData.tags) ? hostData.tags.join(',') : (hostData.tags || ''), + ip: hostData.ip, + port: hostData.port, + username: hostData.username, + authType: hostData.authType, + pin: !!hostData.pin ? 1 : 0, + enableTerminal: !!hostData.enableTerminal ? 1 : 0, + enableTunnel: !!hostData.enableTunnel ? 1 : 0, + tunnelConnections: Array.isArray(hostData.tunnelConnections) ? JSON.stringify(hostData.tunnelConnections) : null, + enableConfigEditor: !!hostData.enableConfigEditor ? 1 : 0, + defaultPath: hostData.defaultPath || null, + }; + + if (hostData.authType === 'password') { + sshDataObj.password = hostData.password; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; + } else if (hostData.authType === 'key') { + sshDataObj.key = hostData.key; + sshDataObj.keyPassword = hostData.keyPassword || null; + sshDataObj.keyType = hostData.keyType || null; + sshDataObj.password = null; + } + + await db.insert(sshData).values(sshDataObj); + results.success++; + + } catch (err) { + results.failed++; + results.errors.push(`Host ${i + 1}: ${err instanceof Error ? err.message : 'Unknown error'}`); + logger.error(`Failed to import host ${i + 1}:`, err); + } + } + + if (results.success > 0) { + logger.success(`Bulk import completed: ${results.success} successful, ${results.failed} failed`); + } else { + logger.warn(`Bulk import failed: ${results.failed} failed`); + } + + res.json({ + message: `Import completed: ${results.success} successful, ${results.failed} failed`, + ...results + }); +}); + export default router; \ No newline at end of file diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 783408aa..ac7c3dd9 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -13,7 +13,7 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str let jwksUrl: string | null = null; const normalizedIssuerUrl = issuerUrl.endsWith('/') ? issuerUrl.slice(0, -1) : issuerUrl; - + try { const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; const discoveryResponse = await fetch(discoveryUrl); @@ -59,12 +59,12 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str logger.warn(`Authentik root JWKS URL also failed: ${error}`); } } - + const jwksResponse = await fetch(jwksUrl); if (!jwksResponse.ok) { throw new Error(`Failed to fetch JWKS from ${jwksUrl}: ${jwksResponse.status}`); } - + const jwks = await jwksResponse.json() as any; const header = JSON.parse(Buffer.from(idToken.split('.')[0], 'base64').toString()); @@ -75,14 +75,14 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str throw new Error(`No matching public key found for key ID: ${keyId}`); } - const { importJWK, jwtVerify } = await import('jose'); + const {importJWK, jwtVerify} = await import('jose'); const key = await importJWK(publicKey); - const { payload } = await jwtVerify(idToken, key, { + const {payload} = await jwtVerify(idToken, key, { issuer: issuerUrl, audience: clientId, }); - + return payload; } catch (error) { logger.error('OIDC token verification failed:', error); @@ -157,14 +157,14 @@ router.post('/create', async (req, res) => { } } catch (e) { } - + const {username, password} = req.body; - + if (!isNonEmptyString(username) || !isNonEmptyString(password)) { logger.warn('Invalid user creation attempt - missing username or password'); - return res.status(400).json({ error: 'Username and password are required' }); + return res.status(400).json({error: 'Username and password are required'}); } - + try { const existing = await db .select() @@ -174,7 +174,7 @@ router.post('/create', async (req, res) => { logger.warn(`Attempt to create duplicate username: ${username}`); return res.status(409).json({error: 'Username already exists'}); } - + let isFirstUser = false; try { const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get(); @@ -182,7 +182,7 @@ router.post('/create', async (req, res) => { } catch (e) { isFirstUser = true; } - + const saltRounds = parseInt(process.env.SALT || '10', 10); const password_hash = await bcrypt.hash(password, saltRounds); const id = nanoid(); @@ -220,7 +220,7 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { if (!user || user.length === 0 || !user[0].is_admin) { return res.status(403).json({error: 'Not authorized'}); } - + const { client_id, client_secret, @@ -231,10 +231,10 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { name_path, scopes } = req.body; - - if (!isNonEmptyString(client_id) || !isNonEmptyString(client_secret) || + + if (!isNonEmptyString(client_id) || !isNonEmptyString(client_secret) || !isNonEmptyString(issuer_url) || !isNonEmptyString(authorization_url) || - !isNonEmptyString(token_url) || !isNonEmptyString(identifier_path) || + !isNonEmptyString(token_url) || !isNonEmptyString(identifier_path) || !isNonEmptyString(name_path)) { return res.status(400).json({error: 'All OIDC configuration fields are required'}); } @@ -249,7 +249,7 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { name_path, scopes: scopes || 'openid email profile' }; - + db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)").run(JSON.stringify(config)); res.json({message: 'OIDC configuration updated'}); @@ -282,7 +282,7 @@ router.get('/oidc/authorize', async (req, res) => { if (!row) { return res.status(404).json({error: 'OIDC not configured'}); } - + const config = JSON.parse((row as any).value); const state = nanoid(); const nonce = nanoid(); @@ -292,13 +292,13 @@ router.get('/oidc/authorize', async (req, res) => { if (origin.includes('localhost')) { origin = 'http://localhost:8081'; } - + const redirectUri = `${origin}/users/oidc/callback`; db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(`oidc_state_${state}`, nonce); db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(`oidc_redirect_${state}`, redirectUri); - + const authUrl = new URL(config.authorization_url); authUrl.searchParams.set('client_id', config.client_id); authUrl.searchParams.set('redirect_uri', redirectUri); @@ -318,7 +318,7 @@ router.get('/oidc/authorize', async (req, res) => { // GET /users/oidc/callback router.get('/oidc/callback', async (req, res) => { const {code, state} = req.query; - + if (!isNonEmptyString(code) || !isNonEmptyString(state)) { return res.status(400).json({error: 'Code and state are required'}); } @@ -328,7 +328,7 @@ router.get('/oidc/callback', async (req, res) => { return res.status(400).json({error: 'Invalid state parameter - redirect URI not found'}); } const redirectUri = (storedRedirectRow as any).value; - + try { const storedNonce = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`oidc_state_${state}`); if (!storedNonce) { @@ -342,9 +342,9 @@ router.get('/oidc/callback', async (req, res) => { if (!configRow) { return res.status(500).json({error: 'OIDC not configured'}); } - + const config = JSON.parse((configRow as any).value); - + const tokenResponse = await fetch(config.token_url, { method: 'POST', headers: { @@ -358,12 +358,12 @@ router.get('/oidc/callback', async (req, res) => { redirect_uri: redirectUri, }), }); - + if (!tokenResponse.ok) { logger.error('OIDC token exchange failed', await tokenResponse.text()); return res.status(400).json({error: 'Failed to exchange authorization code'}); } - + const tokenData = await tokenResponse.json() as any; let userInfo; @@ -376,13 +376,13 @@ router.get('/oidc/callback', async (req, res) => { const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url; const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ''); const userInfoUrl = `${baseUrl}/userinfo/`; - + const userInfoResponse = await fetch(userInfoUrl, { headers: { 'Authorization': `Bearer ${tokenData.access_token}`, }, }); - + if (userInfoResponse.ok) { userInfo = await userInfoResponse.json(); } else { @@ -394,27 +394,27 @@ router.get('/oidc/callback', async (req, res) => { const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url; const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ''); const userInfoUrl = `${baseUrl}/userinfo/`; - + const userInfoResponse = await fetch(userInfoUrl, { headers: { 'Authorization': `Bearer ${tokenData.access_token}`, }, }); - + if (userInfoResponse.ok) { userInfo = await userInfoResponse.json(); } else { logger.error(`Userinfo endpoint failed with status: ${userInfoResponse.status}`); } } - + if (!userInfo) { return res.status(400).json({error: 'Failed to get user information'}); } const identifier = userInfo[config.identifier_path]; const name = userInfo[config.name_path] || identifier; - + if (!identifier) { logger.error(`Identifier not found at path: ${config.identifier_path}`); logger.error(`Available fields: ${Object.keys(userInfo).join(', ')}`); @@ -425,7 +425,7 @@ router.get('/oidc/callback', async (req, res) => { .select() .from(users) .where(and(eq(users.is_oidc, true), eq(users.oidc_identifier, identifier))); - + let isFirstUser = false; if (!user || user.length === 0) { try { @@ -452,14 +452,14 @@ router.get('/oidc/callback', async (req, res) => { name_path: config.name_path, scopes: config.scopes, }); - + user = await db .select() .from(users) .where(eq(users.id, id)); } else { await db.update(users) - .set({ username: name }) + .set({username: name}) .where(eq(users.id, user[0].id)); user = await db @@ -467,11 +467,11 @@ router.get('/oidc/callback', async (req, res) => { .from(users) .where(eq(users.id, user[0].id)); } - + const userRecord = user[0]; const jwtSecret = process.env.JWT_SECRET || 'secret'; - const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { + const token = jwt.sign({userId: userRecord.id}, jwtSecret, { expiresIn: '50d', }); @@ -480,13 +480,13 @@ router.get('/oidc/callback', async (req, res) => { if (frontendUrl.includes('localhost')) { frontendUrl = 'http://localhost:5173'; } - + const redirectUrl = new URL(frontendUrl); redirectUrl.searchParams.set('success', 'true'); redirectUrl.searchParams.set('token', token); res.redirect(redirectUrl.toString()); - + } catch (err) { logger.error('OIDC callback failed', err); @@ -495,10 +495,10 @@ router.get('/oidc/callback', async (req, res) => { if (frontendUrl.includes('localhost')) { frontendUrl = 'http://localhost:5173'; } - + const redirectUrl = new URL(frontendUrl); redirectUrl.searchParams.set('error', 'OIDC authentication failed'); - + res.redirect(redirectUrl.toString()); } }); @@ -510,7 +510,7 @@ router.post('/login', async (req, res) => { if (!isNonEmptyString(username) || !isNonEmptyString(password)) { logger.warn('Invalid traditional login attempt'); - return res.status(400).json({ error: 'Invalid username or password' }); + return res.status(400).json({error: 'Invalid username or password'}); } try { @@ -521,27 +521,27 @@ router.post('/login', async (req, res) => { if (!user || user.length === 0) { logger.warn(`User not found: ${username}`); - return res.status(404).json({ error: 'User not found' }); + return res.status(404).json({error: 'User not found'}); } const userRecord = user[0]; if (userRecord.is_oidc) { - return res.status(403).json({ error: 'This user uses external authentication' }); + return res.status(403).json({error: 'This user uses external authentication'}); } const isMatch = await bcrypt.compare(password, userRecord.password_hash); if (!isMatch) { logger.warn(`Incorrect password for user: ${username}`); - return res.status(401).json({ error: 'Incorrect password' }); + return res.status(401).json({error: 'Incorrect password'}); } const jwtSecret = process.env.JWT_SECRET || 'secret'; - const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { + const token = jwt.sign({userId: userRecord.id}, jwtSecret, { expiresIn: '50d', }); - return res.json({ + return res.json({ token, is_admin: !!userRecord.is_admin, username: userRecord.username @@ -549,7 +549,7 @@ router.post('/login', async (req, res) => { } catch (err) { logger.error('Failed to log in user', err); - return res.status(500).json({ error: 'Login failed' }); + return res.status(500).json({error: 'Login failed'}); } }); @@ -571,7 +571,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => { return res.status(401).json({error: 'User not found'}); } res.json({ - username: user[0].username, + username: user[0].username, is_admin: !!user[0].is_admin, is_oidc: !!user[0].is_oidc }); @@ -639,4 +639,351 @@ router.patch('/registration-allowed', authenticateJWT, async (req, res) => { } }); +// Route: Delete user account +// DELETE /users/delete-account +router.delete('/delete-account', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {password} = req.body; + + if (!isNonEmptyString(password)) { + return res.status(400).json({error: 'Password is required to delete account'}); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + const userRecord = user[0]; + + if (userRecord.is_oidc) { + return res.status(403).json({error: 'Cannot delete external authentication accounts through this endpoint'}); + } + + const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (!isMatch) { + logger.warn(`Incorrect password provided for account deletion: ${userRecord.username}`); + return res.status(401).json({error: 'Incorrect password'}); + } + + if (userRecord.is_admin) { + const adminCount = db.$client.prepare('SELECT COUNT(*) as count FROM users WHERE is_admin = 1').get(); + if ((adminCount as any)?.count <= 1) { + return res.status(403).json({error: 'Cannot delete the last admin user'}); + } + } + + await db.delete(users).where(eq(users.id, userId)); + + logger.success(`User account deleted: ${userRecord.username}`); + res.json({message: 'Account deleted successfully'}); + + } catch (err) { + logger.error('Failed to delete user account', err); + res.status(500).json({error: 'Failed to delete account'}); + } +}); + +// Route: Initiate password reset +// POST /users/initiate-reset +router.post('/initiate-reset', async (req, res) => { + const {username} = req.body; + + if (!isNonEmptyString(username)) { + return res.status(400).json({error: 'Username is required'}); + } + + try { + const user = await db + .select() + .from(users) + .where(eq(users.username, username)); + + if (!user || user.length === 0) { + logger.warn(`Password reset attempted for non-existent user: ${username}`); + return res.status(404).json({error: 'User not found'}); + } + + if (user[0].is_oidc) { + return res.status(403).json({error: 'Password reset not available for external authentication users'}); + } + + const resetCode = Math.floor(100000 + Math.random() * 900000).toString(); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + + db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run( + `reset_code_${username}`, + JSON.stringify({code: resetCode, expiresAt: expiresAt.toISOString()}) + ); + + logger.info(`Password reset code for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`); + + res.json({message: 'Password reset code has been generated and logged. Check docker logs for the code.'}); + + } catch (err) { + logger.error('Failed to initiate password reset', err); + res.status(500).json({error: 'Failed to initiate password reset'}); + } +}); + +// Route: Verify reset code +// POST /users/verify-reset-code +router.post('/verify-reset-code', async (req, res) => { + const {username, resetCode} = req.body; + + if (!isNonEmptyString(username) || !isNonEmptyString(resetCode)) { + return res.status(400).json({error: 'Username and reset code are required'}); + } + + try { + const resetDataRow = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`reset_code_${username}`); + if (!resetDataRow) { + return res.status(400).json({error: 'No reset code found for this user'}); + } + + const resetData = JSON.parse((resetDataRow as any).value); + const now = new Date(); + const expiresAt = new Date(resetData.expiresAt); + + if (now > expiresAt) { + db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`reset_code_${username}`); + return res.status(400).json({error: 'Reset code has expired'}); + } + + if (resetData.code !== resetCode) { + return res.status(400).json({error: 'Invalid reset code'}); + } + + const tempToken = nanoid(); + const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000); + + db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run( + `temp_reset_token_${username}`, + JSON.stringify({token: tempToken, expiresAt: tempTokenExpiry.toISOString()}) + ); + + res.json({message: 'Reset code verified', tempToken}); + + } catch (err) { + logger.error('Failed to verify reset code', err); + res.status(500).json({error: 'Failed to verify reset code'}); + } +}); + +// Route: Complete password reset +// POST /users/complete-reset +router.post('/complete-reset', async (req, res) => { + const {username, tempToken, newPassword} = req.body; + + if (!isNonEmptyString(username) || !isNonEmptyString(tempToken) || !isNonEmptyString(newPassword)) { + return res.status(400).json({error: 'Username, temporary token, and new password are required'}); + } + + try { + const tempTokenRow = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`temp_reset_token_${username}`); + if (!tempTokenRow) { + return res.status(400).json({error: 'No temporary token found'}); + } + + const tempTokenData = JSON.parse((tempTokenRow as any).value); + const now = new Date(); + const expiresAt = new Date(tempTokenData.expiresAt); + + if (now > expiresAt) { + // Clean up expired token + db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${username}`); + return res.status(400).json({error: 'Temporary token has expired'}); + } + + if (tempTokenData.token !== tempToken) { + return res.status(400).json({error: 'Invalid temporary token'}); + } + + const saltRounds = parseInt(process.env.SALT || '10', 10); + const password_hash = await bcrypt.hash(newPassword, saltRounds); + + await db.update(users) + .set({password_hash}) + .where(eq(users.username, username)); + + db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`reset_code_${username}`); + db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${username}`); + + logger.success(`Password successfully reset for user: ${username}`); + res.json({message: 'Password has been successfully reset'}); + + } catch (err) { + logger.error('Failed to complete password reset', err); + res.status(500).json({error: 'Failed to complete password reset'}); + } +}); + +// Route: List all users (admin only) +// GET /users/list +router.get('/list', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0 || !user[0].is_admin) { + return res.status(403).json({error: 'Not authorized'}); + } + + const allUsers = await db.select({ + id: users.id, + username: users.username, + is_admin: users.is_admin, + is_oidc: users.is_oidc + }).from(users); + + res.json({users: allUsers}); + } catch (err) { + logger.error('Failed to list users', err); + res.status(500).json({error: 'Failed to list users'}); + } +}); + +// Route: Make user admin (admin only) +// POST /users/make-admin +router.post('/make-admin', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {username} = req.body; + + if (!isNonEmptyString(username)) { + return res.status(400).json({error: 'Username is required'}); + } + + try { + const adminUser = await db.select().from(users).where(eq(users.id, userId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + return res.status(403).json({error: 'Not authorized'}); + } + + const targetUser = await db.select().from(users).where(eq(users.username, username)); + if (!targetUser || targetUser.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + if (targetUser[0].is_admin) { + return res.status(400).json({error: 'User is already an admin'}); + } + + await db.update(users) + .set({is_admin: true}) + .where(eq(users.username, username)); + + logger.success(`User ${username} made admin by ${adminUser[0].username}`); + res.json({message: `User ${username} is now an admin`}); + + } catch (err) { + logger.error('Failed to make user admin', err); + res.status(500).json({error: 'Failed to make user admin'}); + } +}); + +// Route: Remove admin status (admin only) +// POST /users/remove-admin +router.post('/remove-admin', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {username} = req.body; + + if (!isNonEmptyString(username)) { + return res.status(400).json({error: 'Username is required'}); + } + + try { + const adminUser = await db.select().from(users).where(eq(users.id, userId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + return res.status(403).json({error: 'Not authorized'}); + } + + if (adminUser[0].username === username) { + return res.status(400).json({error: 'Cannot remove your own admin status'}); + } + + const targetUser = await db.select().from(users).where(eq(users.username, username)); + if (!targetUser || targetUser.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + if (!targetUser[0].is_admin) { + return res.status(400).json({error: 'User is not an admin'}); + } + + await db.update(users) + .set({is_admin: false}) + .where(eq(users.username, username)); + + logger.success(`Admin status removed from ${username} by ${adminUser[0].username}`); + res.json({message: `Admin status removed from ${username}`}); + + } catch (err) { + logger.error('Failed to remove admin status', err); + res.status(500).json({error: 'Failed to remove admin status'}); + } +}); + +// Route: Delete user (admin only) +// DELETE /users/delete-user +router.delete('/delete-user', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {username} = req.body; + + if (!isNonEmptyString(username)) { + return res.status(400).json({error: 'Username is required'}); + } + + try { + const adminUser = await db.select().from(users).where(eq(users.id, userId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + return res.status(403).json({error: 'Not authorized'}); + } + + if (adminUser[0].username === username) { + return res.status(400).json({error: 'Cannot delete your own account'}); + } + + const targetUser = await db.select().from(users).where(eq(users.username, username)); + if (!targetUser || targetUser.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + if (targetUser[0].is_admin) { + const adminCount = db.$client.prepare('SELECT COUNT(*) as count FROM users WHERE is_admin = 1').get(); + if ((adminCount as any)?.count <= 1) { + return res.status(403).json({error: 'Cannot delete the last admin user'}); + } + } + + const targetUserId = targetUser[0].id; + + try { + db.$client.prepare('DELETE FROM config_editor_recent WHERE user_id = ?').run(targetUserId); + db.$client.prepare('DELETE FROM config_editor_pinned WHERE user_id = ?').run(targetUserId); + db.$client.prepare('DELETE FROM config_editor_shortcuts WHERE user_id = ?').run(targetUserId); + db.$client.prepare('DELETE FROM ssh_data WHERE user_id = ?').run(targetUserId); + } catch (cleanupError) { + logger.error(`Cleanup failed for user ${username}:`, cleanupError); + } + + await db.delete(users).where(eq(users.id, targetUserId)); + + logger.success(`User ${username} deleted by admin ${adminUser[0].username}`); + res.json({message: `User ${username} deleted successfully`}); + + } catch (err) { + logger.error('Failed to delete user', err); + + if (err && typeof err === 'object' && 'code' in err) { + if (err.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') { + res.status(400).json({error: 'Cannot delete user: User has associated data that cannot be removed'}); + } else { + res.status(500).json({error: `Database error: ${err.code}`}); + } + } else { + res.status(500).json({error: 'Failed to delete account'}); + } + } +}); + export default router; \ No newline at end of file diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 00000000..5513a5cd --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 4ee26b38..750c93cd 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -52,7 +52,6 @@ function TooltipContent({ {...props} > {children} - ) From 4e93ac7d88a95a9a6ad0c67fdf1a38addd3b2247 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 12 Aug 2025 13:27:57 -0500 Subject: [PATCH 07/65] Update read me --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fbdb385c..03dca564 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Termix is an open-source, forever-free, self-hosted all-in-one server management - **Modern UI** - Clean interface built with React, Tailwind CSS, and the amazing Shadcn # Planned Features -- **Improved Admin Control** - Ability to manage admins, and give more fine-grained control over their permissions, share hosts, reset passwords, delete accounts, etc +- **Improved Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc - **More auth types** - Add 2FA, TOTP, etc - **Theming** - Modify themeing for all tools - **Improved SFTP Support** - Ability to manage files easier with the config editor by uploading, creating, and removing files From 07a8fc3e50f737a2c910c8fe48b7f2300dd00402 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 12 Aug 2025 14:38:01 -0500 Subject: [PATCH 08/65] Add bulk import path --- docker/nginx.conf | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docker/nginx.conf b/docker/nginx.conf index 909aa46e..e6a83300 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -54,6 +54,15 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location /ssh/bulk-import/ { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /ssh/websocket/ { proxy_pass http://127.0.0.1:8082/; proxy_http_version 1.1; From c71b8b42113505a716ba1b97a27ae12345a76bdb Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 13 Aug 2025 00:05:13 -0500 Subject: [PATCH 09/65] Fix routing for json imports, added dynamic alerts. --- docker/nginx.conf | 9 + src/apps/Homepage/AlertCard.tsx | 150 ++++++++++++ src/apps/Homepage/AlertManager.tsx | 202 ++++++++++++++++ src/apps/Homepage/Homepage.tsx | 35 ++- src/apps/Homepage/HomepageAuth.tsx | 6 + src/apps/Homepage/HomepageSidebar.tsx | 84 +++++-- src/apps/Homepage/HomepageWelcomeCard.tsx | 58 ----- src/apps/SSH/ssh-axios.ts | 6 +- src/backend/database/database.ts | 26 ++- src/backend/database/db/index.ts | 8 + src/backend/database/db/schema.ts | 7 + src/backend/database/routes/alerts.ts | 270 ++++++++++++++++++++++ src/backend/database/routes/ssh.ts | 114 --------- src/backend/database/routes/users.ts | 1 + 14 files changed, 753 insertions(+), 223 deletions(-) create mode 100644 src/apps/Homepage/AlertCard.tsx create mode 100644 src/apps/Homepage/AlertManager.tsx delete mode 100644 src/apps/Homepage/HomepageWelcomeCard.tsx create mode 100644 src/backend/database/routes/alerts.ts diff --git a/docker/nginx.conf b/docker/nginx.conf index e6a83300..848152f0 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -45,6 +45,15 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location /alerts/ { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /ssh/db/ { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; diff --git a/src/apps/Homepage/AlertCard.tsx b/src/apps/Homepage/AlertCard.tsx new file mode 100644 index 00000000..c6850f6e --- /dev/null +++ b/src/apps/Homepage/AlertCard.tsx @@ -0,0 +1,150 @@ +import React from "react"; +import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card"; +import {Button} from "@/components/ui/button"; +import {Badge} from "@/components/ui/badge"; +import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react"; + +interface TermixAlert { + id: string; + title: string; + message: string; + expiresAt: string; + priority?: 'low' | 'medium' | 'high' | 'critical'; + type?: 'info' | 'warning' | 'error' | 'success'; + actionUrl?: string; + actionText?: string; +} + +interface AlertCardProps { + alert: TermixAlert; + onDismiss: (alertId: string) => void; + onClose: () => void; +} + +const getAlertIcon = (type?: string) => { + switch (type) { + case 'warning': + return ; + case 'error': + return ; + case 'success': + return ; + case 'info': + default: + return ; + } +}; + +const getPriorityBadgeVariant = (priority?: string) => { + switch (priority) { + case 'critical': + return 'destructive'; + case 'high': + return 'destructive'; + case 'medium': + return 'secondary'; + case 'low': + default: + return 'outline'; + } +}; + +const getTypeBadgeVariant = (type?: string) => { + switch (type) { + case 'warning': + return 'secondary'; + case 'error': + return 'destructive'; + case 'success': + return 'default'; + case 'info': + default: + return 'outline'; + } +}; + +export function AlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement { + if (!alert) { + return null; + } + + const handleDismiss = () => { + onDismiss(alert.id); + 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 'Expired'; + if (diffDays === 0) return 'Expires today'; + if (diffDays === 1) return 'Expires tomorrow'; + return `Expires in ${diffDays} days`; + }; + + return ( + + +
+
+ {getAlertIcon(alert.type)} + + {alert.title} + +
+ +
+
+ {alert.priority && ( + + {alert.priority.toUpperCase()} + + )} + {alert.type && ( + + {alert.type} + + )} + + {formatExpiryDate(alert.expiresAt)} + +
+
+ +

+ {alert.message} +

+
+ +
+ + {alert.actionUrl && alert.actionText && ( + + )} +
+
+
+ ); +} diff --git a/src/apps/Homepage/AlertManager.tsx b/src/apps/Homepage/AlertManager.tsx new file mode 100644 index 00000000..a9eb0fd1 --- /dev/null +++ b/src/apps/Homepage/AlertManager.tsx @@ -0,0 +1,202 @@ +import React, {useEffect, useState} from "react"; +import {AlertCard} from "./AlertCard"; +import {Button} from "@/components/ui/button"; +import axios from "axios"; + +interface TermixAlert { + id: string; + title: string; + message: string; + expiresAt: string; + priority?: 'low' | 'medium' | 'high' | 'critical'; + type?: 'info' | 'warning' | 'error' | 'success'; + actionUrl?: string; + actionText?: string; +} + +interface AlertManagerProps { + userId: string | null; + loggedIn: boolean; +} + +const apiBase = import.meta.env.DEV ? "http://localhost:8081/alerts" : "/alerts"; + +const API = axios.create({ + baseURL: apiBase, +}); + +export function AlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement { + const [alerts, setAlerts] = useState([]); + const [currentAlertIndex, setCurrentAlertIndex] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (loggedIn && userId) { + fetchUserAlerts(); + } + }, [loggedIn, userId]); + + const fetchUserAlerts = async () => { + if (!userId) return; + + setLoading(true); + setError(null); + + try { + const response = await API.get(`/user/${userId}`); + + const userAlerts = response.data.alerts || []; + + const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => { + const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 }; + const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0; + const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0; + + if (aPriority !== bPriority) { + return bPriority - aPriority; + } + + return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime(); + }); + + setAlerts(sortedAlerts); + setCurrentAlertIndex(0); + } catch (err) { + setError('Failed to load alerts'); + } finally { + setLoading(false); + } + }; + + const handleDismissAlert = async (alertId: string) => { + if (!userId) return; + + try { + const response = await API.post('/dismiss', { + userId, + alertId + }); + + setAlerts(prev => { + const newAlerts = prev.filter(alert => alert.id !== alertId); + return newAlerts; + }); + + setCurrentAlertIndex(prevIndex => { + const newAlertsLength = alerts.length - 1; + if (newAlertsLength === 0) return 0; + if (prevIndex >= newAlertsLength) return Math.max(0, newAlertsLength - 1); + return prevIndex; + }); + } catch (err) { + setError('Failed to dismiss alert'); + } + }; + + const handleCloseCurrentAlert = () => { + if (alerts.length === 0) return; + + if (currentAlertIndex < alerts.length - 1) { + setCurrentAlertIndex(currentAlertIndex + 1); + } else { + setAlerts([]); + setCurrentAlertIndex(0); + } + }; + + const handlePreviousAlert = () => { + if (currentAlertIndex > 0) { + setCurrentAlertIndex(currentAlertIndex - 1); + } + }; + + const handleNextAlert = () => { + if (currentAlertIndex < alerts.length - 1) { + setCurrentAlertIndex(currentAlertIndex + 1); + } + }; + + if (!loggedIn || !userId) { + return null; + } + + if (loading) { + return ( +
+
+
+
+ Loading alerts... +
+
+
+ ); + } + + if (alerts.length === 0) { + return null; + } + + const currentAlert = alerts[currentAlertIndex]; + + if (!currentAlert) { + return null; + } + + const priorityCounts = { critical: 0, high: 0, medium: 0, low: 0 }; + alerts.forEach(alert => { + const priority = alert.priority || 'low'; + priorityCounts[priority as keyof typeof priorityCounts]++; + }); + const hasMultipleAlerts = alerts.length > 1; + + return ( +
+
+ {/* Current Alert */} + + + {/* Navigation Controls */} + {hasMultipleAlerts && ( +
+ + + {currentAlertIndex + 1} of {alerts.length} + + +
+ )} + + {/* Error Display */} + {error && ( +
+
+ {error} +
+
+ )} +
+
+ ); +} diff --git a/src/apps/Homepage/Homepage.tsx b/src/apps/Homepage/Homepage.tsx index 9efe1e67..299203fd 100644 --- a/src/apps/Homepage/Homepage.tsx +++ b/src/apps/Homepage/Homepage.tsx @@ -3,17 +3,12 @@ import React, {useEffect, useState} from "react"; import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx"; import axios from "axios"; import {HomepageUpdateLog} from "@/apps/Homepage/HompageUpdateLog.tsx"; -import {HomepageWelcomeCard} from "@/apps/Homepage/HomepageWelcomeCard.tsx"; +import {AlertManager} from "@/apps/Homepage/AlertManager.tsx"; interface HomepageProps { onSelectView: (view: string) => void; } -function setCookie(name: string, value: string, days = 7) { - const expires = new Date(Date.now() + days * 864e5).toUTCString(); - document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; -} - function getCookie(name: string) { return document.cookie.split('; ').reduce((r, v) => { const parts = v.split('='); @@ -21,6 +16,11 @@ function getCookie(name: string) { }, ""); } +function setCookie(name: string, value: string, days = 7) { + const expires = new Date(Date.now() + days * 864e5).toUTCString(); + document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; +} + const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users"; const API = axios.create({ @@ -31,13 +31,12 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement { const [loggedIn, setLoggedIn] = useState(false); const [isAdmin, setIsAdmin] = useState(false); const [username, setUsername] = useState(null); + const [userId, setUserId] = useState(null); const [authLoading, setAuthLoading] = useState(true); const [dbError, setDbError] = useState(null); - const [showWelcomeCard, setShowWelcomeCard] = useState(true); useEffect(() => { const jwt = getCookie("jwt"); - const welcomeHidden = getCookie("welcome_hidden"); if (jwt) { setAuthLoading(true); @@ -49,13 +48,14 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement { setLoggedIn(true); setIsAdmin(!!meRes.data.is_admin); setUsername(meRes.data.username || null); + setUserId(meRes.data.userId || null); setDbError(null); - setShowWelcomeCard(welcomeHidden !== "true"); }) .catch((err) => { setLoggedIn(false); setIsAdmin(false); setUsername(null); + setUserId(null); setCookie("jwt", "", -1); if (err?.response?.data?.error?.includes("Database")) { setDbError("Could not connect to the database. Please try again later."); @@ -69,11 +69,6 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement { } }, []); - const handleHideWelcomeCard = () => { - setShowWelcomeCard(false); - setCookie("welcome_hidden", "true", 365 * 10); - }; - return ( - {loggedIn && !authLoading && showWelcomeCard && ( -
- -
- )} + {/* Alert Manager - replaces the old welcome card */} +
); diff --git a/src/apps/Homepage/HomepageAuth.tsx b/src/apps/Homepage/HomepageAuth.tsx index 30d3a7ea..e3e62a0c 100644 --- a/src/apps/Homepage/HomepageAuth.tsx +++ b/src/apps/Homepage/HomepageAuth.tsx @@ -29,6 +29,7 @@ interface HomepageAuthProps extends React.ComponentProps<"div"> { setLoggedIn: (loggedIn: boolean) => void; setIsAdmin: (isAdmin: boolean) => void; setUsername: (username: string | null) => void; + setUserId: (userId: string | null) => void; loggedIn: boolean; authLoading: boolean; dbError: string | null; @@ -40,6 +41,7 @@ export function HomepageAuth({ setLoggedIn, setIsAdmin, setUsername, + setUserId, loggedIn, authLoading, dbError, @@ -144,6 +146,7 @@ export function HomepageAuth({ setLoggedIn(true); setIsAdmin(!!meRes.data.is_admin); setUsername(meRes.data.username || null); + setUserId(meRes.data.id || null); setDbError(null); if (tab === "signup") { setSignupConfirmPassword(""); @@ -154,6 +157,7 @@ export function HomepageAuth({ setLoggedIn(false); setIsAdmin(false); setUsername(null); + setUserId(null); setCookie("jwt", "", -1); if (err?.response?.data?.error?.includes("Database")) { setDbError("Could not connect to the database. Please try again later."); @@ -298,6 +302,7 @@ export function HomepageAuth({ setLoggedIn(true); setIsAdmin(!!meRes.data.is_admin); setUsername(meRes.data.username || null); + setUserId(meRes.data.id || null); setDbError(null); window.history.replaceState({}, document.title, window.location.pathname); }) @@ -307,6 +312,7 @@ export function HomepageAuth({ setLoggedIn(false); setIsAdmin(false); setUsername(null); + setUserId(null); setCookie("jwt", "", -1); window.history.replaceState({}, document.title, window.location.pathname); }) diff --git a/src/apps/Homepage/HomepageSidebar.tsx b/src/apps/Homepage/HomepageSidebar.tsx index ec0df53c..a3afac18 100644 --- a/src/apps/Homepage/HomepageSidebar.tsx +++ b/src/apps/Homepage/HomepageSidebar.tsx @@ -119,23 +119,37 @@ export function HomepageSidebar({ React.useEffect(() => { if (adminSheetOpen) { - API.get("/registration-allowed").then(res => { - setAllowRegistration(res.data.allowed); - }); - - API.get("/oidc-config").then(res => { - if (res.data) { - setOidcConfig(res.data); - } - }).catch((error) => { - }); - fetchUsers(); + const jwt = getCookie("jwt"); + if (jwt && isAdmin) { + API.get("/oidc-config").then(res => { + if (res.data) { + setOidcConfig(res.data); + } + }).catch((error) => { + }); + fetchUsers(); + } } else { - fetchAdminCount(); + const jwt = getCookie("jwt"); + if (jwt && isAdmin) { + fetchAdminCount(); + } } - }, [adminSheetOpen]); + }, [adminSheetOpen, isAdmin]); + + React.useEffect(() => { + if (!isAdmin) { + setAdminSheetOpen(false); + setUsers([]); + setAdminCount(0); + } + }, [isAdmin]); const handleToggle = async (checked: boolean) => { + if (!isAdmin) { + return; + } + setRegLoading(true); const jwt = getCookie("jwt"); try { @@ -153,6 +167,11 @@ export function HomepageSidebar({ const handleOIDCConfigSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + if (!isAdmin) { + return; + } + setOidcLoading(true); setOidcError(null); setOidcSuccess(null); @@ -214,8 +233,13 @@ export function HomepageSidebar({ }; const fetchUsers = async () => { - setUsersLoading(true); const jwt = getCookie("jwt"); + + if (!jwt || !isAdmin) { + return; + } + + setUsersLoading(true); try { const response = await API.get("/list", { headers: {Authorization: `Bearer ${jwt}`} @@ -233,6 +257,11 @@ export function HomepageSidebar({ const fetchAdminCount = async () => { const jwt = getCookie("jwt"); + + if (!jwt || !isAdmin) { + return; + } + try { const response = await API.get("/list", { headers: {Authorization: `Bearer ${jwt}`} @@ -248,6 +277,10 @@ export function HomepageSidebar({ e.preventDefault(); if (!newAdminUsername.trim()) return; + if (!isAdmin) { + return; + } + setMakeAdminLoading(true); setMakeAdminError(null); setMakeAdminSuccess(null); @@ -271,6 +304,10 @@ export function HomepageSidebar({ const removeAdminStatus = async (username: string) => { if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return; + if (!isAdmin) { + return; + } + const jwt = getCookie("jwt"); try { await API.post("/remove-admin", @@ -286,6 +323,10 @@ export function HomepageSidebar({ const deleteUser = async (username: string) => { if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return; + if (!isAdmin) { + return; + } + const jwt = getCookie("jwt"); try { await API.delete("/delete-user", { @@ -375,7 +416,11 @@ export function HomepageSidebar({ {isAdmin && ( setAdminSheetOpen(true)}> + onSelect={() => { + if (isAdmin) { + setAdminSheetOpen(true); + } + }}> Admin Settings )} @@ -400,9 +445,12 @@ export function HomepageSidebar({ - {/* Admin Settings Sheet (always rendered, only openable if isAdmin) */} + {/* Admin Settings Sheet */} {isAdmin && ( - + { + if (open && !isAdmin) return; + setAdminSheetOpen(open); + }}> Admin Settings @@ -510,7 +558,7 @@ export function HomepageSidebar({ id="token_url" value={oidcConfig.token_url} onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)} - placeholder="http://100.98.3.50:9000/application/o/token/" + placeholder="https://your-provider.com/application/o/token/" required /> diff --git a/src/apps/Homepage/HomepageWelcomeCard.tsx b/src/apps/Homepage/HomepageWelcomeCard.tsx deleted file mode 100644 index 4ef52ce3..00000000 --- a/src/apps/Homepage/HomepageWelcomeCard.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from "react"; -import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card"; -import {Button} from "@/components/ui/button"; - -interface HomepageWelcomeCardProps { - onHidePermanently: () => void; -} - -export function HomepageWelcomeCard({onHidePermanently}: HomepageWelcomeCardProps): React.ReactElement { - return ( - - - - The Future of Termix - - - -

- Please checkout the linked survey{" "} - - here - - . The purpose of this survey is to gather feedback from users on what the future UI of Termix could - look like to optimize server management. Please take a minute or two to read the survey questions - and answer them to the best of your ability. Thank you! -

-

- A special thanks to those in Asia who recently joined Termix through various forum posts, keep - sharing it! A Chinese translation is planned for Termix, but since I don’t speak Chinese, I’ll need - to hire someone to help with the translation. If you’d like to support me financially, you can do - so{" "} - - here. - -

-
- - - -
- ); -} diff --git a/src/apps/SSH/ssh-axios.ts b/src/apps/SSH/ssh-axios.ts index e2f1eab9..1c7e39d9 100644 --- a/src/apps/SSH/ssh-axios.ts +++ b/src/apps/SSH/ssh-axios.ts @@ -96,21 +96,21 @@ interface ConfigEditorShortcut { const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; const sshHostApi = axios.create({ - baseURL: isLocalhost ? 'http://localhost:8081' : window.location.origin, + baseURL: isLocalhost ? 'http://localhost:8081' : '', headers: { 'Content-Type': 'application/json', }, }); const tunnelApi = axios.create({ - baseURL: isLocalhost ? 'http://localhost:8083' : window.location.origin, + baseURL: isLocalhost ? 'http://localhost:8083' : '', headers: { 'Content-Type': 'application/json', }, }); const configEditorApi = axios.create({ - baseURL: isLocalhost ? 'http://localhost:8084' : window.location.origin, + baseURL: isLocalhost ? 'http://localhost:8084' : '', headers: { 'Content-Type': 'application/json', } diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 40d02983..a59f1ffd 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -2,6 +2,7 @@ import express from 'express'; import bodyParser from 'body-parser'; import userRoutes from './routes/users.js'; import sshRoutes from './routes/ssh.js'; +import alertRoutes from './routes/alerts.js'; import chalk from 'chalk'; import cors from 'cors'; import fetch from 'node-fetch'; @@ -101,10 +102,10 @@ interface GitHubRelease { async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise { const cachedData = githubCache.get(cacheKey); if (cachedData) { - return { - data: cachedData, - cached: true, - cache_age: Date.now() - cachedData.timestamp + return { + data: cachedData, + cached: true, + cache_age: Date.now() - cachedData.timestamp }; } @@ -124,10 +125,10 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise const data = await response.json(); githubCache.set(cacheKey, data); - - return { - data: data, - cached: false + + return { + data: data, + cached: false }; } catch (error) { logger.error(`Failed to fetch from GitHub API: ${endpoint}`, error); @@ -227,12 +228,16 @@ app.get('/releases/rss', async (req, res) => { res.json(response); } catch (error) { logger.error('Failed to generate RSS format', error) - res.status(500).json({ error: 'Failed to generate RSS format', details: error instanceof Error ? error.message : 'Unknown error' }); + res.status(500).json({ + error: 'Failed to generate RSS format', + details: error instanceof Error ? error.message : 'Unknown error' + }); } }); app.use('/users', userRoutes); app.use('/ssh', sshRoutes); +app.use('/alerts', alertRoutes); app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { logger.error('Unhandled error:', err); @@ -240,4 +245,5 @@ app.use((err: unknown, req: express.Request, res: express.Response, next: expres }); const PORT = 8081; -app.listen(PORT, () => {}); \ No newline at end of file +app.listen(PORT, () => { +}); \ No newline at end of file diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index ae87ab6d..b41e4729 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -121,6 +121,14 @@ sqlite.exec(` FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (host_id) REFERENCES ssh_data(id) ); + + CREATE TABLE IF NOT EXISTS dismissed_alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + alert_id TEXT NOT NULL, + dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); `); const addColumnIfNotExists = (table: string, column: string, definition: string) => { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 652a5686..b7ecc68f 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -73,4 +73,11 @@ export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', { name: text('name').notNull(), path: text('path').notNull(), createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), +}); + +export const dismissedAlerts = sqliteTable('dismissed_alerts', { + id: integer('id').primaryKey({autoIncrement: true}), + userId: text('user_id').notNull().references(() => users.id), + alertId: text('alert_id').notNull(), + dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`), }); \ No newline at end of file diff --git a/src/backend/database/routes/alerts.ts b/src/backend/database/routes/alerts.ts new file mode 100644 index 00000000..f38cef89 --- /dev/null +++ b/src/backend/database/routes/alerts.ts @@ -0,0 +1,270 @@ +import express from 'express'; +import {db} from '../db/index.js'; +import {dismissedAlerts} from '../db/schema.js'; +import {eq, and} from 'drizzle-orm'; +import chalk from 'chalk'; +import fetch from 'node-fetch'; +import type {Request, Response, NextFunction} from 'express'; + +const dbIconSymbol = '🚨'; +const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); +const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { + return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#dc2626')(`[${dbIconSymbol}]`)} ${message}`; +}; +const logger = { + info: (msg: string): void => { + console.log(formatMessage('info', chalk.cyan, msg)); + }, + warn: (msg: string): void => { + console.warn(formatMessage('warn', chalk.yellow, msg)); + }, + error: (msg: string, err?: unknown): void => { + console.error(formatMessage('error', chalk.redBright, msg)); + if (err) console.error(err); + }, + success: (msg: string): void => { + console.log(formatMessage('success', chalk.greenBright, msg)); + }, + debug: (msg: string): void => { + if (process.env.NODE_ENV !== 'production') { + console.debug(formatMessage('debug', chalk.magenta, msg)); + } + } +}; + +interface CacheEntry { + data: any; + timestamp: number; + expiresAt: number; +} + +class AlertCache { + private cache: Map = new Map(); + private readonly CACHE_DURATION = 5 * 60 * 1000; + + set(key: string, data: any): void { + const now = Date.now(); + this.cache.set(key, { + data, + timestamp: now, + expiresAt: now + this.CACHE_DURATION + }); + } + + get(key: string): any | null { + const entry = this.cache.get(key); + if (!entry) { + return null; + } + + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return null; + } + + return entry.data; + } +} + +const alertCache = new AlertCache(); + +const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com'; +const REPO_OWNER = 'LukeGus'; +const REPO_NAME = 'Termix-Docs'; +const ALERTS_FILE = 'main/termix-alerts.json'; + +interface TermixAlert { + id: string; + title: string; + message: string; + expiresAt: string; + priority?: 'low' | 'medium' | 'high' | 'critical'; + type?: 'info' | 'warning' | 'error' | 'success'; + actionUrl?: string; + actionText?: string; +} + +async function fetchAlertsFromGitHub(): Promise { + const cacheKey = 'termix_alerts'; + const cachedData = alertCache.get(cacheKey); + if (cachedData) { + return cachedData; + } + + try { + const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`; + + const response = await fetch(url, { + headers: { + 'Accept': 'application/json', + 'User-Agent': 'TermixAlertChecker/1.0' + } + }); + + if (!response.ok) { + throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`); + } + + const alerts: TermixAlert[] = await response.json() as TermixAlert[]; + + const now = new Date(); + + const validAlerts = alerts.filter(alert => { + const expiryDate = new Date(alert.expiresAt); + const isValid = expiryDate > now; + return isValid; + }); + + alertCache.set(cacheKey, validAlerts); + return validAlerts; + } catch (error) { + logger.error('Failed to fetch alerts from GitHub', error); + return []; + } +} + +const router = express.Router(); + +// Route: Get all active alerts +// GET /alerts +router.get('/', async (req, res) => { + try { + const alerts = await fetchAlertsFromGitHub(); + res.json({ + alerts, + cached: alertCache.get('termix_alerts') !== null, + total_count: alerts.length + }); + } catch (error) { + logger.error('Failed to get alerts', error); + res.status(500).json({error: 'Failed to fetch alerts'}); + } +}); + +// Route: Get alerts for a specific user (excluding dismissed ones) +// GET /alerts/user/:userId +router.get('/user/:userId', async (req, res) => { + try { + const {userId} = req.params; + + if (!userId) { + return res.status(400).json({error: 'User ID is required'}); + } + + const allAlerts = await fetchAlertsFromGitHub(); + + const dismissedAlertRecords = await db + .select({alertId: dismissedAlerts.alertId}) + .from(dismissedAlerts) + .where(eq(dismissedAlerts.userId, userId)); + + const dismissedAlertIds = new Set(dismissedAlertRecords.map(record => record.alertId)); + + const userAlerts = allAlerts.filter(alert => !dismissedAlertIds.has(alert.id)); + + res.json({ + alerts: userAlerts, + total_count: userAlerts.length, + dismissed_count: dismissedAlertIds.size + }); + } catch (error) { + logger.error('Failed to get user alerts', error); + res.status(500).json({error: 'Failed to fetch user alerts'}); + } +}); + +// Route: Dismiss an alert for a user +// POST /alerts/dismiss +router.post('/dismiss', async (req, res) => { + try { + const {userId, alertId} = req.body; + + if (!userId || !alertId) { + logger.warn('Missing userId or alertId in dismiss request'); + return res.status(400).json({error: 'User ID and Alert ID are required'}); + } + + const existingDismissal = await db + .select() + .from(dismissedAlerts) + .where(and( + eq(dismissedAlerts.userId, userId), + eq(dismissedAlerts.alertId, alertId) + )); + + if (existingDismissal.length > 0) { + logger.warn(`Alert ${alertId} already dismissed by user ${userId}`); + return res.status(409).json({error: 'Alert already dismissed'}); + } + + const result = await db.insert(dismissedAlerts).values({ + userId, + alertId + }); + + logger.success(`Alert ${alertId} dismissed by user ${userId}. Insert result: ${JSON.stringify(result)}`); + res.json({message: 'Alert dismissed successfully'}); + } catch (error) { + logger.error('Failed to dismiss alert', error); + res.status(500).json({error: 'Failed to dismiss alert'}); + } +}); + +// Route: Get dismissed alerts for a user +// GET /alerts/dismissed/:userId +router.get('/dismissed/:userId', async (req, res) => { + try { + const {userId} = req.params; + + if (!userId) { + return res.status(400).json({error: 'User ID is required'}); + } + + const dismissedAlertRecords = await db + .select({ + alertId: dismissedAlerts.alertId, + dismissedAt: dismissedAlerts.dismissedAt + }) + .from(dismissedAlerts) + .where(eq(dismissedAlerts.userId, userId)); + + res.json({ + dismissed_alerts: dismissedAlertRecords, + total_count: dismissedAlertRecords.length + }); + } catch (error) { + logger.error('Failed to get dismissed alerts', error); + res.status(500).json({error: 'Failed to fetch dismissed alerts'}); + } +}); + +// Route: Undismiss an alert for a user (remove from dismissed list) +// DELETE /alerts/dismiss +router.delete('/dismiss', async (req, res) => { + try { + const {userId, alertId} = req.body; + + if (!userId || !alertId) { + return res.status(400).json({error: 'User ID and Alert ID are required'}); + } + + const result = await db + .delete(dismissedAlerts) + .where(and( + eq(dismissedAlerts.userId, userId), + eq(dismissedAlerts.alertId, alertId) + )); + + if (result.changes === 0) { + return res.status(404).json({error: 'Dismissed alert not found'}); + } + + logger.success(`Alert ${alertId} undismissed by user ${userId}`); + res.json({message: 'Alert undismissed successfully'}); + } catch (error) { + logger.error('Failed to undismiss alert', error); + res.status(500).json({error: 'Failed to undismiss alert'}); + } +}); + +export default router; diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 819af76a..18b8416a 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -464,7 +464,6 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: .set({lastOpened: new Date().toISOString()}) .where(and(...conditions)); } else { - // Add new recent file await db.insert(configEditorRecent).values({ userId, hostId, @@ -692,117 +691,4 @@ router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, } }); -// Route: Bulk import SSH hosts from JSON (requires JWT) -// POST /ssh/bulk-import -router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; - const {hosts} = req.body; - - if (!Array.isArray(hosts) || hosts.length === 0) { - logger.warn('Invalid bulk import data - hosts array is required and must not be empty'); - return res.status(400).json({error: 'Hosts array is required and must not be empty'}); - } - - if (hosts.length > 100) { - logger.warn(`Bulk import attempted with too many hosts: ${hosts.length}`); - return res.status(400).json({error: 'Maximum 100 hosts allowed per import'}); - } - - const results = { - success: 0, - failed: 0, - errors: [] as string[] - }; - - for (let i = 0; i < hosts.length; i++) { - const hostData = hosts[i]; - - try { - if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) { - results.failed++; - results.errors.push(`Host ${i + 1}: Missing or invalid required fields (ip, port, username)`); - continue; - } - - if (hostData.authType !== 'password' && hostData.authType !== 'key') { - results.failed++; - results.errors.push(`Host ${i + 1}: Invalid authType. Must be 'password' or 'key'`); - continue; - } - - if (hostData.authType === 'password' && !isNonEmptyString(hostData.password)) { - results.failed++; - results.errors.push(`Host ${i + 1}: Password required for password authentication`); - continue; - } - - if (hostData.authType === 'key' && !isNonEmptyString(hostData.key)) { - results.failed++; - results.errors.push(`Host ${i + 1}: SSH key required for key authentication`); - continue; - } - - // Validate tunnel connections if enabled - if (hostData.enableTunnel && Array.isArray(hostData.tunnelConnections)) { - for (let j = 0; j < hostData.tunnelConnections.length; j++) { - const conn = hostData.tunnelConnections[j]; - if (!isValidPort(conn.sourcePort) || !isValidPort(conn.endpointPort) || !isNonEmptyString(conn.endpointHost)) { - results.failed++; - results.errors.push(`Host ${i + 1}, Tunnel ${j + 1}: Invalid tunnel connection data`); - break; - } - } - } - - const sshDataObj: any = { - userId: userId, - name: hostData.name || '', - folder: hostData.folder || '', - tags: Array.isArray(hostData.tags) ? hostData.tags.join(',') : (hostData.tags || ''), - ip: hostData.ip, - port: hostData.port, - username: hostData.username, - authType: hostData.authType, - pin: !!hostData.pin ? 1 : 0, - enableTerminal: !!hostData.enableTerminal ? 1 : 0, - enableTunnel: !!hostData.enableTunnel ? 1 : 0, - tunnelConnections: Array.isArray(hostData.tunnelConnections) ? JSON.stringify(hostData.tunnelConnections) : null, - enableConfigEditor: !!hostData.enableConfigEditor ? 1 : 0, - defaultPath: hostData.defaultPath || null, - }; - - if (hostData.authType === 'password') { - sshDataObj.password = hostData.password; - sshDataObj.key = null; - sshDataObj.keyPassword = null; - sshDataObj.keyType = null; - } else if (hostData.authType === 'key') { - sshDataObj.key = hostData.key; - sshDataObj.keyPassword = hostData.keyPassword || null; - sshDataObj.keyType = hostData.keyType || null; - sshDataObj.password = null; - } - - await db.insert(sshData).values(sshDataObj); - results.success++; - - } catch (err) { - results.failed++; - results.errors.push(`Host ${i + 1}: ${err instanceof Error ? err.message : 'Unknown error'}`); - logger.error(`Failed to import host ${i + 1}:`, err); - } - } - - if (results.success > 0) { - logger.success(`Bulk import completed: ${results.success} successful, ${results.failed} failed`); - } else { - logger.warn(`Bulk import failed: ${results.failed} failed`); - } - - res.json({ - message: `Import completed: ${results.success} successful, ${results.failed} failed`, - ...results - }); -}); - export default router; \ No newline at end of file diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index ac7c3dd9..98985d5e 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -571,6 +571,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => { return res.status(401).json({error: 'User not found'}); } res.json({ + userId: user[0].id, username: user[0].username, is_admin: !!user[0].is_admin, is_oidc: !!user[0].is_oidc From 47f8d3f23bdeb0ae0b3b515caa53715b4c30b30a Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 13 Aug 2025 00:26:32 -0500 Subject: [PATCH 10/65] Re add ssh.ts bulk add backend --- src/backend/database/routes/ssh.ts | 112 +++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 18b8416a..2d1bec89 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -691,4 +691,116 @@ router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, } }); +// Route: Bulk import SSH hosts from JSON (requires JWT) +// POST /ssh/bulk-import +router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const { hosts } = req.body; + + if (!Array.isArray(hosts) || hosts.length === 0) { + logger.warn('Invalid bulk import data - hosts array is required and must not be empty'); + return res.status(400).json({error: 'Hosts array is required and must not be empty'}); + } + + if (hosts.length > 100) { + logger.warn(`Bulk import attempted with too many hosts: ${hosts.length}`); + return res.status(400).json({error: 'Maximum 100 hosts allowed per import'}); + } + + const results = { + success: 0, + failed: 0, + errors: [] as string[] + }; + + for (let i = 0; i < hosts.length; i++) { + const hostData = hosts[i]; + + try { + if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) { + results.failed++; + results.errors.push(`Host ${i + 1}: Missing or invalid required fields (ip, port, username)`); + continue; + } + + if (hostData.authType !== 'password' && hostData.authType !== 'key') { + results.failed++; + results.errors.push(`Host ${i + 1}: Invalid authType. Must be 'password' or 'key'`); + continue; + } + + if (hostData.authType === 'password' && !isNonEmptyString(hostData.password)) { + results.failed++; + results.errors.push(`Host ${i + 1}: Password required for password authentication`); + continue; + } + + if (hostData.authType === 'key' && !isNonEmptyString(hostData.key)) { + results.failed++; + results.errors.push(`Host ${i + 1}: SSH key required for key authentication`); + continue; + } + + if (hostData.enableTunnel && Array.isArray(hostData.tunnelConnections)) { + for (let j = 0; j < hostData.tunnelConnections.length; j++) { + const conn = hostData.tunnelConnections[j]; + if (!isValidPort(conn.sourcePort) || !isValidPort(conn.endpointPort) || !isNonEmptyString(conn.endpointHost)) { + results.failed++; + results.errors.push(`Host ${i + 1}, Tunnel ${j + 1}: Invalid tunnel connection data`); + break; + } + } + } + + const sshDataObj: any = { + userId: userId, + name: hostData.name || '', + folder: hostData.folder || '', + tags: Array.isArray(hostData.tags) ? hostData.tags.join(',') : (hostData.tags || ''), + ip: hostData.ip, + port: hostData.port, + username: hostData.username, + authType: hostData.authType, + pin: !!hostData.pin ? 1 : 0, + enableTerminal: !!hostData.enableTerminal ? 1 : 0, + enableTunnel: !!hostData.enableTunnel ? 1 : 0, + tunnelConnections: Array.isArray(hostData.tunnelConnections) ? JSON.stringify(hostData.tunnelConnections) : null, + enableConfigEditor: !!hostData.enableConfigEditor ? 1 : 0, + defaultPath: hostData.defaultPath || null, + }; + + if (hostData.authType === 'password') { + sshDataObj.password = hostData.password; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; + } else if (hostData.authType === 'key') { + sshDataObj.key = hostData.key; + sshDataObj.keyPassword = hostData.keyPassword || null; + sshDataObj.keyType = hostData.keyType || null; + sshDataObj.password = null; + } + + await db.insert(sshData).values(sshDataObj); + results.success++; + + } catch (err) { + results.failed++; + results.errors.push(`Host ${i + 1}: ${err instanceof Error ? err.message : 'Unknown error'}`); + logger.error(`Failed to import host ${i + 1}:`, err); + } + } + + if (results.success > 0) { + logger.success(`Bulk import completed: ${results.success} successful, ${results.failed} failed`); + } else { + logger.warn(`Bulk import failed: ${results.failed} failed`); + } + + res.json({ + message: `Import completed: ${results.success} successful, ${results.failed} failed`, + ...results + }); +}); + export default router; \ No newline at end of file From b9d5965aa63b0c97b17477c1ccbca3ec22ef8e0a Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 13 Aug 2025 01:44:40 -0500 Subject: [PATCH 11/65] Fix nginx.conf for json import routing --- docker/nginx.conf | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/docker/nginx.conf b/docker/nginx.conf index 848152f0..c332661a 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -54,16 +54,7 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } - location /ssh/db/ { - proxy_pass http://127.0.0.1:8081; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - location /ssh/bulk-import/ { + location /ssh/ { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; proxy_set_header Host $host; @@ -103,15 +94,6 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } - location ~ ^/ssh/config_editor/(recent|pinned|shortcuts) { - proxy_pass http://127.0.0.1:8081; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; From 3a663f1b47b4931a9191352317edebdd8827e2e1 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 13 Aug 2025 11:58:10 -0500 Subject: [PATCH 12/65] Add sepeator betwen json stuff and refresh button --- src/apps/SSH/Manager/SSHManagerHostViewer.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx index e94a8d17..25f53247 100644 --- a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx +++ b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx @@ -7,7 +7,21 @@ import {Input} from "@/components/ui/input"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion"; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/apps/SSH/ssh-axios"; -import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search, Upload, Info} from "lucide-react"; +import { + Edit, + Trash2, + Server, + Folder, + Tag, + Pin, + Terminal, + Network, + FileEdit, + Search, + Upload, + Info +} from "lucide-react"; +import {Separator} from "@/components/ui/separator.tsx"; interface SSHHost { id: number; @@ -565,6 +579,8 @@ EXAMPLE STRUCTURE: Format Guide +
+ From 96864dbeb4f3cdfce3a16e594d6101ce7d8b733f Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 13 Aug 2025 11:59:50 -0500 Subject: [PATCH 13/65] Fix admin tag format/styling --- src/apps/Homepage/HomepageSidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/apps/Homepage/HomepageSidebar.tsx b/src/apps/Homepage/HomepageSidebar.tsx index a3afac18..45a60801 100644 --- a/src/apps/Homepage/HomepageSidebar.tsx +++ b/src/apps/Homepage/HomepageSidebar.tsx @@ -681,7 +681,7 @@ export function HomepageSidebar({ {user.username} {user.is_admin && ( + className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border"> Admin )} @@ -771,7 +771,7 @@ export function HomepageSidebar({ {admin.username} + className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border"> Admin From f62bb7f773d7217ef75feefe17d394c11adc7173 Mon Sep 17 00:00:00 2001 From: Karmaa <88517757+LukeGus@users.noreply.github.com> Date: Wed, 13 Aug 2025 13:08:09 -0500 Subject: [PATCH 14/65] Update .env --- .env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env b/.env index 27929cde..955cc3f4 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -VERSION=1.1.1 \ No newline at end of file +VERSION=1.2 From 81d1db09e4fe462c116542cea1edcf849c75c8cf Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 14 Aug 2025 01:24:05 -0500 Subject: [PATCH 15/65] Initial UI commit for 1.3 --- .env | 2 +- src/App.tsx | 123 +++++++++++++----- src/apps/SSH/Manager/SSHManagerSidebar.tsx | 59 --------- src/apps/Template/Template.tsx | 18 --- src/apps/Template/TemplateSidebar.tsx | 58 --------- src/components/ui/sidebar.tsx | 4 +- src/{apps => ui}/Homepage/Homepage.tsx | 53 ++++---- .../Homepage/HomepageAlertCard.tsx} | 8 +- .../Homepage/HomepageAlertManager.tsx} | 11 +- src/{apps => ui}/Homepage/HomepageAuth.tsx | 11 +- .../Homepage/HompageUpdateLog.tsx | 0 .../Navigation/Sidebar.tsx} | 39 ++++-- .../SSH/Config Editor/ConfigCodeEditor.tsx | 0 .../SSH/Config Editor/ConfigEditor.tsx | 12 +- .../SSH/Config Editor/ConfigEditorSidebar.tsx | 2 +- .../Config Editor/ConfigFileSidebarViewer.tsx | 0 .../SSH/Config Editor/ConfigHomeView.tsx | 0 .../SSH/Config Editor/ConfigTabList.tsx | 0 .../SSH/Config Editor/ConfigTopbar.tsx | 0 src/{apps => ui}/SSH/Manager/SSHManager.tsx | 25 ++-- .../SSH/Manager/SSHManagerHostEditor.tsx | 21 ++- .../SSH/Manager/SSHManagerHostViewer.tsx | 2 +- src/{apps => ui}/SSH/Terminal/Terminal.tsx | 4 +- .../SSH/Terminal/TerminalComponent.tsx | 0 .../SSH/Terminal/TerminalSidebar.tsx | 2 +- .../SSH/Terminal/TerminalTabList.tsx | 0 .../SSH/Terminal/TerminalTopbar.tsx | 2 +- src/{apps => ui}/SSH/Tunnel/SSHTunnel.tsx | 6 +- .../SSH/Tunnel/SSHTunnelObject.tsx | 0 .../SSH/Tunnel/SSHTunnelSidebar.tsx | 0 .../SSH/Tunnel/SSHTunnelViewer.tsx | 0 src/{apps => ui}/SSH/ssh-axios.ts | 0 tsconfig.app.json | 6 - 33 files changed, 196 insertions(+), 272 deletions(-) delete mode 100644 src/apps/SSH/Manager/SSHManagerSidebar.tsx delete mode 100644 src/apps/Template/Template.tsx delete mode 100644 src/apps/Template/TemplateSidebar.tsx rename src/{apps => ui}/Homepage/Homepage.tsx (64%) rename src/{apps/Homepage/AlertCard.tsx => ui/Homepage/HomepageAlertCard.tsx} (95%) rename src/{apps/Homepage/AlertManager.tsx => ui/Homepage/HomepageAlertManager.tsx} (95%) rename src/{apps => ui}/Homepage/HomepageAuth.tsx (99%) rename src/{apps => ui}/Homepage/HompageUpdateLog.tsx (100%) rename src/{apps/Homepage/HomepageSidebar.tsx => ui/Navigation/Sidebar.tsx} (97%) rename src/{apps => ui}/SSH/Config Editor/ConfigCodeEditor.tsx (100%) rename src/{apps => ui}/SSH/Config Editor/ConfigEditor.tsx (98%) rename src/{apps => ui}/SSH/Config Editor/ConfigEditorSidebar.tsx (99%) rename src/{apps => ui}/SSH/Config Editor/ConfigFileSidebarViewer.tsx (100%) rename src/{apps => ui}/SSH/Config Editor/ConfigHomeView.tsx (100%) rename src/{apps => ui}/SSH/Config Editor/ConfigTabList.tsx (100%) rename src/{apps => ui}/SSH/Config Editor/ConfigTopbar.tsx (100%) rename src/{apps => ui}/SSH/Manager/SSHManager.tsx (77%) rename src/{apps => ui}/SSH/Manager/SSHManagerHostEditor.tsx (98%) rename src/{apps => ui}/SSH/Manager/SSHManagerHostViewer.tsx (99%) rename src/{apps => ui}/SSH/Terminal/Terminal.tsx (99%) rename src/{apps => ui}/SSH/Terminal/TerminalComponent.tsx (100%) rename src/{apps => ui}/SSH/Terminal/TerminalSidebar.tsx (99%) rename src/{apps => ui}/SSH/Terminal/TerminalTabList.tsx (100%) rename src/{apps => ui}/SSH/Terminal/TerminalTopbar.tsx (97%) rename src/{apps => ui}/SSH/Tunnel/SSHTunnel.tsx (96%) rename src/{apps => ui}/SSH/Tunnel/SSHTunnelObject.tsx (100%) rename src/{apps => ui}/SSH/Tunnel/SSHTunnelSidebar.tsx (100%) rename src/{apps => ui}/SSH/Tunnel/SSHTunnelViewer.tsx (100%) rename src/{apps => ui}/SSH/ssh-axios.ts (100%) diff --git a/.env b/.env index 27929cde..07925e7a 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -VERSION=1.1.1 \ No newline at end of file +VERSION=1.3 \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index c4510ae9..26f0717a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,64 @@ -import React from "react" +import React, { useState, useEffect } from "react" +import { Sidebar } from "@/ui/Navigation/Sidebar.tsx" +import { Homepage } from "@/ui/Homepage/Homepage.tsx" +import { Terminal } from "@/ui/SSH/Terminal/Terminal.tsx" +import { SSHTunnel } from "@/ui/SSH/Tunnel/SSHTunnel.tsx" +import { ConfigEditor } from "@/ui/SSH/Config Editor/ConfigEditor.tsx" +import { SSHManager } from "@/ui/SSH/Manager/SSHManager.tsx" +import axios from "axios" -import {Homepage} from "@/apps/Homepage/Homepage.tsx" -import {Terminal} from "@/apps/SSH/Terminal/Terminal.tsx" -import {SSHTunnel} from "@/apps/SSH/Tunnel/SSHTunnel.tsx"; -import {ConfigEditor} from "@/apps/SSH/Config Editor/ConfigEditor.tsx"; -import {SSHManager} from "@/apps/SSH/Manager/SSHManager.tsx" +const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users"; +const API = axios.create({ baseURL: apiBase }); + +function getCookie(name: string) { + return document.cookie.split('; ').reduce((r, v) => { + const parts = v.split('='); + return parts[0] === name ? decodeURIComponent(parts[1]) : r; + }, ""); +} function App() { const [view, setView] = React.useState("homepage") const [mountedViews, setMountedViews] = React.useState>(new Set(["homepage"])) + const [isAuthenticated, setIsAuthenticated] = useState(false) + const [username, setUsername] = useState(null) + const [isAdmin, setIsAdmin] = useState(false) + const [authLoading, setAuthLoading] = useState(true) + + useEffect(() => { + const checkAuth = () => { + const jwt = getCookie("jwt"); + if (jwt) { + setAuthLoading(true); + API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}}) + .then((meRes) => { + setIsAuthenticated(true); + setIsAdmin(!!meRes.data.is_admin); + setUsername(meRes.data.username || null); + }) + .catch((err) => { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); + // Clear invalid JWT + document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + }) + .finally(() => setAuthLoading(false)); + } else { + setIsAuthenticated(false); + setIsAdmin(false); + setUsername(null); + setAuthLoading(false); + } + } + + checkAuth() + + const handleStorageChange = () => checkAuth() + window.addEventListener('storage', handleStorageChange) + + return () => window.removeEventListener('storage', handleStorageChange) + }, []) const handleSelectView = (nextView: string) => { setMountedViews((prev) => { @@ -21,35 +71,38 @@ function App() { } return ( -
-
- {mountedViews.has("homepage") && ( -
- -
- )} - {mountedViews.has("ssh_manager") && ( -
- -
- )} - {mountedViews.has("terminal") && ( -
- -
- )} - {mountedViews.has("tunnel") && ( -
- -
- )} - {mountedViews.has("config_editor") && ( -
- -
- )} -
-
+ + {mountedViews.has("homepage") && ( +
+ +
+ )} + {mountedViews.has("ssh_manager") && ( +
+ +
+ )} + {mountedViews.has("terminal") && ( +
+ +
+ )} + {mountedViews.has("tunnel") && ( +
+ +
+ )} + {mountedViews.has("config_editor") && ( +
+ +
+ )} +
) } diff --git a/src/apps/SSH/Manager/SSHManagerSidebar.tsx b/src/apps/SSH/Manager/SSHManagerSidebar.tsx deleted file mode 100644 index 819830f3..00000000 --- a/src/apps/SSH/Manager/SSHManagerSidebar.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; - -import { - CornerDownLeft -} from "lucide-react" - -import { - Button -} from "@/components/ui/button.tsx" - -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuItem, SidebarProvider, -} from "@/components/ui/sidebar.tsx" - -import { - Separator, -} from "@/components/ui/separator.tsx" - -interface SidebarProps { - onSelectView: (view: string) => void; -} - -export function SSHManagerSidebar({onSelectView}: SidebarProps): React.ReactElement { - return ( - - - - - - Termix / SSH Manager - - - - - - {/* Sidebar Items */} - - - - - - - - - - - - ) -} \ No newline at end of file diff --git a/src/apps/Template/Template.tsx b/src/apps/Template/Template.tsx deleted file mode 100644 index 09c02a2e..00000000 --- a/src/apps/Template/Template.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from "react"; -import {TemplateSidebar} from "@/apps/Template/TemplateSidebar.tsx"; - -interface ConfigEditorProps { - onSelectView: (view: string) => void; -} - -export function Template({onSelectView}: ConfigEditorProps): React.ReactElement { - return ( -
- - - Template -
- ) -} \ No newline at end of file diff --git a/src/apps/Template/TemplateSidebar.tsx b/src/apps/Template/TemplateSidebar.tsx deleted file mode 100644 index eb4f903b..00000000 --- a/src/apps/Template/TemplateSidebar.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; - -import { - CornerDownLeft -} from "lucide-react" - -import { - Button -} from "@/components/ui/button.tsx" - -import { - Sidebar, - SidebarContent, - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuItem, SidebarProvider, -} from "@/components/ui/sidebar.tsx" - -import { - Separator, -} from "@/components/ui/separator.tsx" - -interface SidebarProps { - onSelectView: (view: string) => void; -} - -export function TemplateSidebar({onSelectView}: SidebarProps): React.ReactElement { - return ( - - - - - - Termix / Template - - - - - - - - - - - - - - - - - ) -} \ No newline at end of file diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index ad864e24..66aace69 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -234,7 +234,7 @@ function Sidebar({ // Adjust the padding for floating and inset variants. variant === "floating" || variant === "inset" ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" - : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", + : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", className )} {...props} @@ -242,7 +242,7 @@ function Sidebar({
{children}
diff --git a/src/apps/Homepage/Homepage.tsx b/src/ui/Homepage/Homepage.tsx similarity index 64% rename from src/apps/Homepage/Homepage.tsx rename to src/ui/Homepage/Homepage.tsx index 299203fd..00967404 100644 --- a/src/apps/Homepage/Homepage.tsx +++ b/src/ui/Homepage/Homepage.tsx @@ -1,9 +1,8 @@ -import {HomepageSidebar} from "@/apps/Homepage/HomepageSidebar.tsx"; import React, {useEffect, useState} from "react"; -import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx"; +import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx"; import axios from "axios"; -import {HomepageUpdateLog} from "@/apps/Homepage/HompageUpdateLog.tsx"; -import {AlertManager} from "@/apps/Homepage/AlertManager.tsx"; +import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx"; +import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx"; interface HomepageProps { onSelectView: (view: string) => void; @@ -70,35 +69,27 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement { }, []); return ( - -
-
- - -
- - {/* Alert Manager - replaces the old welcome card */} - +
+ +
- + + +
); } \ No newline at end of file diff --git a/src/apps/Homepage/AlertCard.tsx b/src/ui/Homepage/HomepageAlertCard.tsx similarity index 95% rename from src/apps/Homepage/AlertCard.tsx rename to src/ui/Homepage/HomepageAlertCard.tsx index c6850f6e..d2f34722 100644 --- a/src/apps/Homepage/AlertCard.tsx +++ b/src/ui/Homepage/HomepageAlertCard.tsx @@ -1,7 +1,7 @@ import React from "react"; -import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card"; -import {Button} from "@/components/ui/button"; -import {Badge} from "@/components/ui/badge"; +import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card.tsx"; +import {Button} from "@/components/ui/button.tsx"; +import {Badge} from "@/components/ui/badge.tsx"; import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react"; interface TermixAlert { @@ -63,7 +63,7 @@ const getTypeBadgeVariant = (type?: string) => { } }; -export function AlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement { +export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement { if (!alert) { return null; } diff --git a/src/apps/Homepage/AlertManager.tsx b/src/ui/Homepage/HomepageAlertManager.tsx similarity index 95% rename from src/apps/Homepage/AlertManager.tsx rename to src/ui/Homepage/HomepageAlertManager.tsx index a9eb0fd1..07772d2a 100644 --- a/src/apps/Homepage/AlertManager.tsx +++ b/src/ui/Homepage/HomepageAlertManager.tsx @@ -1,6 +1,6 @@ import React, {useEffect, useState} from "react"; -import {AlertCard} from "./AlertCard"; -import {Button} from "@/components/ui/button"; +import {HomepageAlertCard} from "./HomepageAlertCard.tsx"; +import {Button} from "@/components/ui/button.tsx"; import axios from "axios"; interface TermixAlert { @@ -25,7 +25,7 @@ const API = axios.create({ baseURL: apiBase, }); -export function AlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement { +export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement { const [alerts, setAlerts] = useState([]); const [currentAlertIndex, setCurrentAlertIndex] = useState(0); const [loading, setLoading] = useState(false); @@ -154,14 +154,12 @@ export function AlertManager({userId, loggedIn}: AlertManagerProps): React.React return (
- {/* Current Alert */} - - {/* Navigation Controls */} {hasMultipleAlerts && (
+ + + - - Termix - - @@ -893,6 +904,14 @@ export function HomepageSidebar({ {children} + + {!isSidebarOpen && ( +
setIsSidebarOpen(true)} + className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center"> + +
+ )}
) } \ No newline at end of file diff --git a/src/apps/SSH/Config Editor/ConfigCodeEditor.tsx b/src/ui/SSH/Config Editor/ConfigCodeEditor.tsx similarity index 100% rename from src/apps/SSH/Config Editor/ConfigCodeEditor.tsx rename to src/ui/SSH/Config Editor/ConfigCodeEditor.tsx diff --git a/src/apps/SSH/Config Editor/ConfigEditor.tsx b/src/ui/SSH/Config Editor/ConfigEditor.tsx similarity index 98% rename from src/apps/SSH/Config Editor/ConfigEditor.tsx rename to src/ui/SSH/Config Editor/ConfigEditor.tsx index 7251ba77..6ac5eb6f 100644 --- a/src/apps/SSH/Config Editor/ConfigEditor.tsx +++ b/src/ui/SSH/Config Editor/ConfigEditor.tsx @@ -1,10 +1,10 @@ import React, {useState, useEffect, useRef} from "react"; -import {ConfigEditorSidebar} from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx"; -import {ConfigTabList} from "@/apps/SSH/Config Editor/ConfigTabList.tsx"; -import {ConfigHomeView} from "@/apps/SSH/Config Editor/ConfigHomeView.tsx"; -import {ConfigCodeEditor} from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx"; +import {ConfigEditorSidebar} from "@/ui/SSH/Config Editor/ConfigEditorSidebar.tsx"; +import {ConfigTabList} from "@/ui/SSH/Config Editor/ConfigTabList.tsx"; +import {ConfigHomeView} from "@/ui/SSH/Config Editor/ConfigHomeView.tsx"; +import {ConfigCodeEditor} from "@/ui/SSH/Config Editor/ConfigCodeEditor.tsx"; import {Button} from '@/components/ui/button.tsx'; -import {ConfigTopbar} from "@/apps/SSH/Config Editor/ConfigTopbar.tsx"; +import {ConfigTopbar} from "@/ui/SSH/Config Editor/ConfigTopbar.tsx"; import {cn} from '@/lib/utils.ts'; import { getConfigEditorRecent, @@ -20,7 +20,7 @@ import { writeSSHFile, getSSHStatus, connectSSH -} from '@/apps/SSH/ssh-axios.ts'; +} from '@/ui/SSH/ssh-axios.ts'; interface Tab { id: string | number; diff --git a/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx b/src/ui/SSH/Config Editor/ConfigEditorSidebar.tsx similarity index 99% rename from src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx rename to src/ui/SSH/Config Editor/ConfigEditorSidebar.tsx index 571182f3..22579660 100644 --- a/src/apps/SSH/Config Editor/ConfigEditorSidebar.tsx +++ b/src/ui/SSH/Config Editor/ConfigEditorSidebar.tsx @@ -22,7 +22,7 @@ import { getConfigEditorPinned, addConfigEditorPinned, removeConfigEditorPinned -} from '@/apps/SSH/ssh-axios.ts'; +} from '@/ui/SSH/ssh-axios.ts'; interface SSHHost { id: number; diff --git a/src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx b/src/ui/SSH/Config Editor/ConfigFileSidebarViewer.tsx similarity index 100% rename from src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx rename to src/ui/SSH/Config Editor/ConfigFileSidebarViewer.tsx diff --git a/src/apps/SSH/Config Editor/ConfigHomeView.tsx b/src/ui/SSH/Config Editor/ConfigHomeView.tsx similarity index 100% rename from src/apps/SSH/Config Editor/ConfigHomeView.tsx rename to src/ui/SSH/Config Editor/ConfigHomeView.tsx diff --git a/src/apps/SSH/Config Editor/ConfigTabList.tsx b/src/ui/SSH/Config Editor/ConfigTabList.tsx similarity index 100% rename from src/apps/SSH/Config Editor/ConfigTabList.tsx rename to src/ui/SSH/Config Editor/ConfigTabList.tsx diff --git a/src/apps/SSH/Config Editor/ConfigTopbar.tsx b/src/ui/SSH/Config Editor/ConfigTopbar.tsx similarity index 100% rename from src/apps/SSH/Config Editor/ConfigTopbar.tsx rename to src/ui/SSH/Config Editor/ConfigTopbar.tsx diff --git a/src/apps/SSH/Manager/SSHManager.tsx b/src/ui/SSH/Manager/SSHManager.tsx similarity index 77% rename from src/apps/SSH/Manager/SSHManager.tsx rename to src/ui/SSH/Manager/SSHManager.tsx index 90c11750..7c3348f3 100644 --- a/src/apps/SSH/Manager/SSHManager.tsx +++ b/src/ui/SSH/Manager/SSHManager.tsx @@ -1,9 +1,9 @@ import React, {useState} from "react"; -import {SSHManagerSidebar} from "@/apps/SSH/Manager/SSHManagerSidebar.tsx"; -import {SSHManagerHostViewer} from "@/apps/SSH/Manager/SSHManagerHostViewer.tsx" +import {SSHManagerHostViewer} from "@/ui/SSH/Manager/SSHManagerHostViewer.tsx" import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx"; import {Separator} from "@/components/ui/separator.tsx"; -import {SSHManagerHostEditor} from "@/apps/SSH/Manager/SSHManagerHostEditor.tsx"; +import {SSHManagerHostEditor} from "@/ui/SSH/Manager/SSHManagerHostEditor.tsx"; +import {useSidebar} from "@/components/ui/sidebar.tsx"; interface ConfigEditorProps { onSelectView: (view: string) => void; @@ -35,6 +35,7 @@ interface SSHHost { export function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElement { const [activeTab, setActiveTab] = useState("host_viewer"); const [editingHost, setEditingHost] = useState(null); + const {state: sidebarState} = useSidebar(); const handleEditHost = (host: SSHHost) => { setEditingHost(host); @@ -55,29 +56,25 @@ export function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElemen return (
- - -
-
- +
+ className={`flex-1 bg-[#18181b] m-[8px] text-white p-4 pt-0 rounded-lg border border-[#303032] flex flex-col min-h-0 ${ + sidebarState === 'collapsed' ? 'ml-6' : '' + }`}> - + Host Viewer {editingHost ? "Edit Host" : "Add Host"} - + - +
- + General @@ -396,7 +396,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost Tunnel Config Editor - + Connection Details
-
- - +
+ +
diff --git a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx b/src/ui/SSH/Manager/SSHManagerHostViewer.tsx similarity index 99% rename from src/apps/SSH/Manager/SSHManagerHostViewer.tsx rename to src/ui/SSH/Manager/SSHManagerHostViewer.tsx index 25f53247..76da3e66 100644 --- a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx +++ b/src/ui/SSH/Manager/SSHManagerHostViewer.tsx @@ -6,7 +6,7 @@ import {ScrollArea} from "@/components/ui/scroll-area"; import {Input} from "@/components/ui/input"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion"; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; -import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/apps/SSH/ssh-axios"; +import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/SSH/ssh-axios"; import { Edit, Trash2, diff --git a/src/apps/SSH/Terminal/Terminal.tsx b/src/ui/SSH/Terminal/Terminal.tsx similarity index 99% rename from src/apps/SSH/Terminal/Terminal.tsx rename to src/ui/SSH/Terminal/Terminal.tsx index e6e92600..a812f41c 100644 --- a/src/apps/SSH/Terminal/Terminal.tsx +++ b/src/ui/SSH/Terminal/Terminal.tsx @@ -1,7 +1,7 @@ import React, {useState, useRef, useEffect} from "react"; -import {TerminalSidebar} from "@/apps/SSH/Terminal/TerminalSidebar.tsx"; +import {TerminalSidebar} from "@/ui/SSH/Terminal/TerminalSidebar.tsx"; import {TerminalComponent} from "./TerminalComponent.tsx"; -import {TerminalTopbar} from "@/apps/SSH/Terminal/TerminalTopbar.tsx"; +import {TerminalTopbar} from "@/ui/SSH/Terminal/TerminalTopbar.tsx"; import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx'; import * as ResizablePrimitive from "react-resizable-panels"; import {ChevronDown, ChevronRight} from "lucide-react"; diff --git a/src/apps/SSH/Terminal/TerminalComponent.tsx b/src/ui/SSH/Terminal/TerminalComponent.tsx similarity index 100% rename from src/apps/SSH/Terminal/TerminalComponent.tsx rename to src/ui/SSH/Terminal/TerminalComponent.tsx diff --git a/src/apps/SSH/Terminal/TerminalSidebar.tsx b/src/ui/SSH/Terminal/TerminalSidebar.tsx similarity index 99% rename from src/apps/SSH/Terminal/TerminalSidebar.tsx rename to src/ui/SSH/Terminal/TerminalSidebar.tsx index 90318520..03d901cd 100644 --- a/src/apps/SSH/Terminal/TerminalSidebar.tsx +++ b/src/ui/SSH/Terminal/TerminalSidebar.tsx @@ -37,7 +37,7 @@ import { } from "@/components/ui/accordion.tsx"; import {ScrollArea} from "@/components/ui/scroll-area.tsx"; import {Input} from "@/components/ui/input.tsx"; -import {getSSHHosts} from "@/apps/SSH/ssh-axios"; +import {getSSHHosts} from "@/ui/SSH/ssh-axios"; import {Checkbox} from "@/components/ui/checkbox.tsx"; interface SSHHost { diff --git a/src/apps/SSH/Terminal/TerminalTabList.tsx b/src/ui/SSH/Terminal/TerminalTabList.tsx similarity index 100% rename from src/apps/SSH/Terminal/TerminalTabList.tsx rename to src/ui/SSH/Terminal/TerminalTabList.tsx diff --git a/src/apps/SSH/Terminal/TerminalTopbar.tsx b/src/ui/SSH/Terminal/TerminalTopbar.tsx similarity index 97% rename from src/apps/SSH/Terminal/TerminalTopbar.tsx rename to src/ui/SSH/Terminal/TerminalTopbar.tsx index 036ae622..7bc30aba 100644 --- a/src/apps/SSH/Terminal/TerminalTopbar.tsx +++ b/src/ui/SSH/Terminal/TerminalTopbar.tsx @@ -1,4 +1,4 @@ -import {TerminalTabList} from "@/apps/SSH/Terminal/TerminalTabList.tsx"; +import {TerminalTabList} from "@/ui/SSH/Terminal/TerminalTabList.tsx"; import React from "react"; import {ChevronUp} from "lucide-react"; diff --git a/src/apps/SSH/Tunnel/SSHTunnel.tsx b/src/ui/SSH/Tunnel/SSHTunnel.tsx similarity index 96% rename from src/apps/SSH/Tunnel/SSHTunnel.tsx rename to src/ui/SSH/Tunnel/SSHTunnel.tsx index 83221fa2..fe9cc13a 100644 --- a/src/apps/SSH/Tunnel/SSHTunnel.tsx +++ b/src/ui/SSH/Tunnel/SSHTunnel.tsx @@ -1,7 +1,7 @@ import React, {useState, useEffect, useCallback} from "react"; -import {SSHTunnelSidebar} from "@/apps/SSH/Tunnel/SSHTunnelSidebar.tsx"; -import {SSHTunnelViewer} from "@/apps/SSH/Tunnel/SSHTunnelViewer.tsx"; -import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/apps/SSH/ssh-axios"; +import {SSHTunnelSidebar} from "@/ui/SSH/Tunnel/SSHTunnelSidebar.tsx"; +import {SSHTunnelViewer} from "@/ui/SSH/Tunnel/SSHTunnelViewer.tsx"; +import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/SSH/ssh-axios"; interface ConfigEditorProps { onSelectView: (view: string) => void; diff --git a/src/apps/SSH/Tunnel/SSHTunnelObject.tsx b/src/ui/SSH/Tunnel/SSHTunnelObject.tsx similarity index 100% rename from src/apps/SSH/Tunnel/SSHTunnelObject.tsx rename to src/ui/SSH/Tunnel/SSHTunnelObject.tsx diff --git a/src/apps/SSH/Tunnel/SSHTunnelSidebar.tsx b/src/ui/SSH/Tunnel/SSHTunnelSidebar.tsx similarity index 100% rename from src/apps/SSH/Tunnel/SSHTunnelSidebar.tsx rename to src/ui/SSH/Tunnel/SSHTunnelSidebar.tsx diff --git a/src/apps/SSH/Tunnel/SSHTunnelViewer.tsx b/src/ui/SSH/Tunnel/SSHTunnelViewer.tsx similarity index 100% rename from src/apps/SSH/Tunnel/SSHTunnelViewer.tsx rename to src/ui/SSH/Tunnel/SSHTunnelViewer.tsx diff --git a/src/apps/SSH/ssh-axios.ts b/src/ui/SSH/ssh-axios.ts similarity index 100% rename from src/apps/SSH/ssh-axios.ts rename to src/ui/SSH/ssh-axios.ts diff --git a/tsconfig.app.json b/tsconfig.app.json index 0c7eded9..d9340376 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -6,16 +6,12 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, - - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", - - /* Linting - Made extremely permissive */ "strict": false, "noUnusedLocals": false, "noUnusedParameters": false, @@ -31,8 +27,6 @@ "allowUnreachableCode": true, "noImplicitOverride": false, "noEmitOnError": false, - - /* shadcn */ "baseUrl": ".", "paths": { "@/*": [ From 1b076cc61263fe1c1acdcd796eb2c675f954dc71 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 14 Aug 2025 01:28:47 -0500 Subject: [PATCH 16/65] Build error fixes --- package-lock.json | 193 ++++++++++++++++-- package.json | 4 +- src/App.tsx | 6 +- src/components/ui/sidebar.tsx | 9 +- .../{Sidebar.tsx => LeftSidebar.tsx} | 2 +- 5 files changed, 187 insertions(+), 27 deletions(-) rename src/ui/Navigation/{Sidebar.tsx => LeftSidebar.tsx} (99%) diff --git a/package-lock.json b/package-lock.json index 262b5166..96ec5142 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", - "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", @@ -24,7 +24,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", - "@radix-ui/react-tooltip": "^1.2.7", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", "@types/multer": "^2.0.0", @@ -1739,20 +1739,20 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", - "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.10", - "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", @@ -1774,6 +1774,78 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -2345,19 +2417,19 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", - "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.2", + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", @@ -2378,6 +2450,95 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", diff --git a/package.json b/package.json index 16eabebe..1edabae8 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", - "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", @@ -28,7 +28,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", - "@radix-ui/react-tooltip": "^1.2.7", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", "@types/multer": "^2.0.0", diff --git a/src/App.tsx b/src/App.tsx index 26f0717a..d0d4606e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react" -import { Sidebar } from "@/ui/Navigation/Sidebar.tsx" +import { LeftSidebar } from "@/ui/Navigation/LeftSidebar.tsx" import { Homepage } from "@/ui/Homepage/Homepage.tsx" import { Terminal } from "@/ui/SSH/Terminal/Terminal.tsx" import { SSHTunnel } from "@/ui/SSH/Tunnel/SSHTunnel.tsx" @@ -71,7 +71,7 @@ function App() { } return ( -
)} - + ) } diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 66aace69..5d8d3cad 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" +import { cva, VariantProps } from "class-variance-authority" import { PanelLeftIcon } from "lucide-react" import { useIsMobile } from "@/hooks/use-mobile" @@ -137,7 +137,7 @@ function SidebarProvider({ } as React.CSSProperties } className={cn( - "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-full w-full", + "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", className )} {...props} @@ -234,7 +234,7 @@ function Sidebar({ // Adjust the padding for floating and inset variants. variant === "floating" || variant === "inset" ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" - : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)", + : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", className )} {...props} @@ -242,7 +242,7 @@ function Sidebar({
{children}
@@ -720,6 +720,5 @@ export { SidebarRail, SidebarSeparator, SidebarTrigger, - // eslint-disable-next-line react-refresh/only-export-components useSidebar, } diff --git a/src/ui/Navigation/Sidebar.tsx b/src/ui/Navigation/LeftSidebar.tsx similarity index 99% rename from src/ui/Navigation/Sidebar.tsx rename to src/ui/Navigation/LeftSidebar.tsx index b56eddce..6ee63189 100644 --- a/src/ui/Navigation/Sidebar.tsx +++ b/src/ui/Navigation/LeftSidebar.tsx @@ -74,7 +74,7 @@ const API = axios.create({ baseURL: apiBase, }); -export function Sidebar({ +export function LeftSidebar({ onSelectView, getView, disabled, From 07367b24b6407c3a0167fbce95d3a07b049a4f61 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 14 Aug 2025 01:47:22 -0500 Subject: [PATCH 17/65] Added navbar --- src/App.tsx | 88 ++++++++++++++++--------------- src/ui/Navigation/TopNavbar.tsx | 19 +++++++ src/ui/SSH/Manager/SSHManager.tsx | 2 +- 3 files changed, 66 insertions(+), 43 deletions(-) create mode 100644 src/ui/Navigation/TopNavbar.tsx diff --git a/src/App.tsx b/src/App.tsx index d0d4606e..7381b0d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,15 @@ -import React, { useState, useEffect } from "react" -import { LeftSidebar } from "@/ui/Navigation/LeftSidebar.tsx" -import { Homepage } from "@/ui/Homepage/Homepage.tsx" -import { Terminal } from "@/ui/SSH/Terminal/Terminal.tsx" -import { SSHTunnel } from "@/ui/SSH/Tunnel/SSHTunnel.tsx" -import { ConfigEditor } from "@/ui/SSH/Config Editor/ConfigEditor.tsx" -import { SSHManager } from "@/ui/SSH/Manager/SSHManager.tsx" +import React, {useState, useEffect} from "react" +import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx" +import {Homepage} from "@/ui/Homepage/Homepage.tsx" +import {Terminal} from "@/ui/SSH/Terminal/Terminal.tsx" +import {SSHTunnel} from "@/ui/SSH/Tunnel/SSHTunnel.tsx" +import {ConfigEditor} from "@/ui/SSH/Config Editor/ConfigEditor.tsx" +import {SSHManager} from "@/ui/SSH/Manager/SSHManager.tsx" import axios from "axios" +import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx"; const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users"; -const API = axios.create({ baseURL: apiBase }); +const API = axios.create({baseURL: apiBase}); function getCookie(name: string) { return document.cookie.split('; ').reduce((r, v) => { @@ -51,12 +52,12 @@ function App() { setAuthLoading(false); } } - + checkAuth() const handleStorageChange = () => checkAuth() window.addEventListener('storage', handleStorageChange) - + return () => window.removeEventListener('storage', handleStorageChange) }, []) @@ -71,38 +72,41 @@ function App() { } return ( - - {mountedViews.has("homepage") && ( -
- -
- )} - {mountedViews.has("ssh_manager") && ( -
- -
- )} - {mountedViews.has("terminal") && ( -
- -
- )} - {mountedViews.has("tunnel") && ( -
- -
- )} - {mountedViews.has("config_editor") && ( -
- -
- )} -
+
+ + {mountedViews.has("homepage") && ( +
+ +
+ )} + {mountedViews.has("ssh_manager") && ( +
+ +
+ )} + {mountedViews.has("terminal") && ( +
+ +
+ )} + {mountedViews.has("tunnel") && ( +
+ +
+ )} + {mountedViews.has("config_editor") && ( +
+ +
+ )} + +
+
) } diff --git a/src/ui/Navigation/TopNavbar.tsx b/src/ui/Navigation/TopNavbar.tsx new file mode 100644 index 00000000..235f7d1f --- /dev/null +++ b/src/ui/Navigation/TopNavbar.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { useSidebar } from "@/components/ui/sidebar"; + +export function TopNavbar(): React.ReactElement { + const { state } = useSidebar(); + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/src/ui/SSH/Manager/SSHManager.tsx b/src/ui/SSH/Manager/SSHManager.tsx index 7c3348f3..3209f11b 100644 --- a/src/ui/SSH/Manager/SSHManager.tsx +++ b/src/ui/SSH/Manager/SSHManager.tsx @@ -58,7 +58,7 @@ export function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElemen
Date: Fri, 15 Aug 2025 01:01:04 -0500 Subject: [PATCH 18/65] UI upadte, added host system, better flex scaling, improved login. --- src/App.tsx | 133 ++++++++--- src/components/ui/button-group.tsx | 53 +++++ src/components/ui/shadcn-io/status/index.tsx | 62 +++++ src/components/ui/sidebar.tsx | 2 +- src/ui/Homepage/Homepage.tsx | 73 +++--- src/ui/Homepage/HomepageAlertManager.tsx | 15 +- src/ui/Homepage/HomepageAuth.tsx | 47 ++-- src/ui/Navigation/Hosts/FolderCard.tsx | 80 +++++++ src/ui/Navigation/Hosts/Host.tsx | 67 ++++++ src/ui/Navigation/LeftSidebar.tsx | 236 +++++++++++++++---- src/ui/Navigation/TopNavbar.tsx | 53 ++++- 11 files changed, 669 insertions(+), 152 deletions(-) create mode 100644 src/components/ui/button-group.tsx create mode 100644 src/components/ui/shadcn-io/status/index.tsx create mode 100644 src/ui/Navigation/Hosts/FolderCard.tsx create mode 100644 src/ui/Navigation/Hosts/Host.tsx diff --git a/src/App.tsx b/src/App.tsx index 7381b0d4..8d16f38f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,13 +18,19 @@ function getCookie(name: string) { }, ""); } +function setCookie(name: string, value: string, days = 7) { + const expires = new Date(Date.now() + days * 864e5).toUTCString(); + document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; +} + function App() { - const [view, setView] = React.useState("homepage") - const [mountedViews, setMountedViews] = React.useState>(new Set(["homepage"])) + const [view, setView] = useState("homepage") + const [mountedViews, setMountedViews] = useState>(new Set(["homepage"])) const [isAuthenticated, setIsAuthenticated] = useState(false) const [username, setUsername] = useState(null) const [isAdmin, setIsAdmin] = useState(false) const [authLoading, setAuthLoading] = useState(true) + const [isTopbarOpen, setIsTopbarOpen] = useState(true) useEffect(() => { const checkAuth = () => { @@ -71,41 +77,102 @@ function App() { setView(nextView) } + const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => { + setIsAuthenticated(true) + setIsAdmin(authData.isAdmin) + setUsername(authData.username) + } + return (
- - {mountedViews.has("homepage") && ( -
- + {/* Enhanced background overlay - detailed pattern when not authenticated */} + {!isAuthenticated && !authLoading && ( +