diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..89762628 --- /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: "/docker" + 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 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/README.md b/README.md index 64243c8f..03dca564 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,12 @@ 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 - **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/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"] diff --git a/docker/nginx.conf b/docker/nginx.conf index 909aa46e..c332661a 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -45,7 +45,16 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } - location /ssh/db/ { + 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/ { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; proxy_set_header Host $host; @@ -85,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; 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 3ad1b24a..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,15 +41,17 @@ export function HomepageAuth({ setLoggedIn, setIsAdmin, setUsername, + setUserId, loggedIn, authLoading, dbError, 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 +60,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 +112,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}); } @@ -118,13 +146,18 @@ 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(""); + } } catch (err: any) { setError(err?.response?.data?.error || "Unknown error"); setInternalLoggedIn(false); 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."); @@ -136,6 +169,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); @@ -178,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); }) @@ -187,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); }) @@ -301,7 +427,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 +445,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 +464,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 +480,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 +680,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..45a60801 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,22 +99,57 @@ 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 => { - setAllowRegistration(res.data.allowed); - }); - - API.get("/oidc-config").then(res => { - if (res.data) { - setOidcConfig(res.data); - } - }).catch((error) => { - }); + const jwt = getCookie("jwt"); + if (jwt && isAdmin) { + API.get("/oidc-config").then(res => { + if (res.data) { + setOidcConfig(res.data); + } + }).catch((error) => { + }); + fetchUsers(); + } + } else { + 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 { @@ -123,19 +167,24 @@ export function HomepageSidebar({ const handleOIDCConfigSubmit = async (e: React.FormEvent) => { e.preventDefault(); + + if (!isAdmin) { + return; + } + setOidcLoading(true); setOidcError(null); setOidcSuccess(null); 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 +207,138 @@ 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 () => { + const jwt = getCookie("jwt"); + + if (!jwt || !isAdmin) { + return; + } + + setUsersLoading(true); + 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"); + + if (!jwt || !isAdmin) { + return; + } + + 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; + + if (!isAdmin) { + 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; + + if (!isAdmin) { + 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; + + if (!isAdmin) { + 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 +382,8 @@ export function HomepageSidebar({
- window.open("https://dashix.dev", "_blank")} disabled={disabled}> + window.open("https://dashix.dev", "_blank")} + disabled={disabled}> Tools @@ -234,7 +416,11 @@ export function HomepageSidebar({ {isAdmin && ( setAdminSheetOpen(true)}> + onSelect={() => { + if (isAdmin) { + setAdminSheetOpen(true); + } + }}> Admin Settings )} @@ -243,183 +429,379 @@ export function HomepageSidebar({ onSelect={handleLogout}> Sign out + setDeleteAccountOpen(true)} + disabled={isAdmin && adminCount <= 1} + > + + Delete Account + {isAdmin && adminCount <= 1 && " (Last Admin)"} + + - {/* Admin Settings Sheet (always rendered, only openable if isAdmin) */} + {/* Admin Settings Sheet */} {isAdmin && ( - - - + { + if (open && !isAdmin) return; + setAdminSheetOpen(open); + }}> + + 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="https://your-provider.com/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 +810,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/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/Manager/SSHManagerHostViewer.tsx b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx index 7fa5e714..25f53247 100644 --- a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx +++ b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx @@ -5,8 +5,23 @@ 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"; +import {Separator} from "@/components/ui/separator.tsx"; interface SSHHost { id: number; @@ -36,6 +51,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 +87,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 +229,372 @@ 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/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 841b3799..2d1bec89 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; } @@ -469,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, @@ -697,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 diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 783408aa..98985d5e 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,8 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => { return res.status(401).json({error: 'User not found'}); } res.json({ - username: user[0].username, + userId: user[0].id, + username: user[0].username, is_admin: !!user[0].is_admin, is_oidc: !!user[0].is_oidc }); @@ -639,4 +640,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} - )