Fix routing for json imports, added dynamic alerts.

This commit is contained in:
LukeGus
2025-08-13 00:05:13 -05:00
parent 07a8fc3e50
commit c71b8b4211
14 changed files with 753 additions and 223 deletions

View File

@@ -45,6 +45,15 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; 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/ { location /ssh/db/ {
proxy_pass http://127.0.0.1:8081; proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1; proxy_http_version 1.1;

View File

@@ -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 <AlertTriangle className="h-5 w-5 text-yellow-500"/>;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500"/>;
case 'success':
return <CheckCircle className="h-5 w-5 text-green-500"/>;
case 'info':
default:
return <Info className="h-5 w-5 text-blue-500"/>;
}
};
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 (
<Card className="w-full max-w-2xl mx-auto">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{getAlertIcon(alert.type)}
<CardTitle className="text-xl font-bold">
{alert.title}
</CardTitle>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4"/>
</Button>
</div>
<div className="flex items-center gap-2 mt-2">
{alert.priority && (
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
{alert.priority.toUpperCase()}
</Badge>
)}
{alert.type && (
<Badge variant={getTypeBadgeVariant(alert.type)}>
{alert.type}
</Badge>
)}
<span className="text-sm text-muted-foreground">
{formatExpiryDate(alert.expiresAt)}
</span>
</div>
</CardHeader>
<CardContent className="pb-4">
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
{alert.message}
</p>
</CardContent>
<CardFooter className="flex items-center justify-between pt-0">
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleDismiss}
>
Dismiss
</Button>
{alert.actionUrl && alert.actionText && (
<Button
variant="default"
onClick={() => window.open(alert.actionUrl, '_blank', 'noopener,noreferrer')}
className="gap-2"
>
{alert.actionText}
<ExternalLink className="h-4 w-4"/>
</Button>
)}
</div>
</CardFooter>
</Card>
);
}

View File

@@ -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<TermixAlert[]>([]);
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="fixed top-4 right-4 z-50">
<div className="bg-background border rounded-lg p-3 shadow-lg">
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span className="text-sm text-muted-foreground">Loading alerts...</span>
</div>
</div>
</div>
);
}
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 (
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-10">
<div className="relative w-full max-w-2xl mx-4">
{/* Current Alert */}
<AlertCard
alert={currentAlert}
onDismiss={handleDismissAlert}
onClose={handleCloseCurrentAlert}
/>
{/* Navigation Controls */}
{hasMultipleAlerts && (
<div className="absolute -bottom-16 left-1/2 transform -translate-x-1/2 flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePreviousAlert}
disabled={currentAlertIndex === 0}
className="h-8 px-3"
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
{currentAlertIndex + 1} of {alerts.length}
</span>
<Button
variant="outline"
size="sm"
onClick={handleNextAlert}
disabled={currentAlertIndex === alerts.length - 1}
className="h-8 px-3"
>
Next
</Button>
</div>
)}
{/* Error Display */}
{error && (
<div className="absolute -bottom-20 left-1/2 transform -translate-x-1/2">
<div className="bg-destructive text-destructive-foreground px-3 py-1 rounded text-sm">
{error}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -3,17 +3,12 @@ import React, {useEffect, useState} from "react";
import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx"; import {HomepageAuth} from "@/apps/Homepage/HomepageAuth.tsx";
import axios from "axios"; import axios from "axios";
import {HomepageUpdateLog} from "@/apps/Homepage/HompageUpdateLog.tsx"; import {HomepageUpdateLog} from "@/apps/Homepage/HompageUpdateLog.tsx";
import {HomepageWelcomeCard} from "@/apps/Homepage/HomepageWelcomeCard.tsx"; import {AlertManager} from "@/apps/Homepage/AlertManager.tsx";
interface HomepageProps { interface HomepageProps {
onSelectView: (view: string) => void; 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) { function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => { return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('='); 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 apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
const API = axios.create({ const API = axios.create({
@@ -31,13 +31,12 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
const [loggedIn, setLoggedIn] = useState(false); const [loggedIn, setLoggedIn] = useState(false);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [username, setUsername] = useState<string | null>(null); const [username, setUsername] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [authLoading, setAuthLoading] = useState(true); const [authLoading, setAuthLoading] = useState(true);
const [dbError, setDbError] = useState<string | null>(null); const [dbError, setDbError] = useState<string | null>(null);
const [showWelcomeCard, setShowWelcomeCard] = useState(true);
useEffect(() => { useEffect(() => {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
const welcomeHidden = getCookie("welcome_hidden");
if (jwt) { if (jwt) {
setAuthLoading(true); setAuthLoading(true);
@@ -49,13 +48,14 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
setLoggedIn(true); setLoggedIn(true);
setIsAdmin(!!meRes.data.is_admin); setIsAdmin(!!meRes.data.is_admin);
setUsername(meRes.data.username || null); setUsername(meRes.data.username || null);
setUserId(meRes.data.userId || null);
setDbError(null); setDbError(null);
setShowWelcomeCard(welcomeHidden !== "true");
}) })
.catch((err) => { .catch((err) => {
setLoggedIn(false); setLoggedIn(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
setUserId(null);
setCookie("jwt", "", -1); setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) { if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later."); 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 ( return (
<HomepageSidebar <HomepageSidebar
onSelectView={onSelectView} onSelectView={onSelectView}
@@ -87,6 +82,7 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
setLoggedIn={setLoggedIn} setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin} setIsAdmin={setIsAdmin}
setUsername={setUsername} setUsername={setUsername}
setUserId={setUserId}
loggedIn={loggedIn} loggedIn={loggedIn}
authLoading={authLoading} authLoading={authLoading}
dbError={dbError} dbError={dbError}
@@ -97,12 +93,11 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
/> />
</div> </div>
{loggedIn && !authLoading && showWelcomeCard && ( {/* Alert Manager - replaces the old welcome card */}
<div <AlertManager
className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-10"> userId={userId}
<HomepageWelcomeCard onHidePermanently={handleHideWelcomeCard}/> loggedIn={loggedIn}
</div> />
)}
</div> </div>
</HomepageSidebar> </HomepageSidebar>
); );

View File

@@ -29,6 +29,7 @@ interface HomepageAuthProps extends React.ComponentProps<"div"> {
setLoggedIn: (loggedIn: boolean) => void; setLoggedIn: (loggedIn: boolean) => void;
setIsAdmin: (isAdmin: boolean) => void; setIsAdmin: (isAdmin: boolean) => void;
setUsername: (username: string | null) => void; setUsername: (username: string | null) => void;
setUserId: (userId: string | null) => void;
loggedIn: boolean; loggedIn: boolean;
authLoading: boolean; authLoading: boolean;
dbError: string | null; dbError: string | null;
@@ -40,6 +41,7 @@ export function HomepageAuth({
setLoggedIn, setLoggedIn,
setIsAdmin, setIsAdmin,
setUsername, setUsername,
setUserId,
loggedIn, loggedIn,
authLoading, authLoading,
dbError, dbError,
@@ -144,6 +146,7 @@ export function HomepageAuth({
setLoggedIn(true); setLoggedIn(true);
setIsAdmin(!!meRes.data.is_admin); setIsAdmin(!!meRes.data.is_admin);
setUsername(meRes.data.username || null); setUsername(meRes.data.username || null);
setUserId(meRes.data.id || null);
setDbError(null); setDbError(null);
if (tab === "signup") { if (tab === "signup") {
setSignupConfirmPassword(""); setSignupConfirmPassword("");
@@ -154,6 +157,7 @@ export function HomepageAuth({
setLoggedIn(false); setLoggedIn(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
setUserId(null);
setCookie("jwt", "", -1); setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) { if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later."); setDbError("Could not connect to the database. Please try again later.");
@@ -298,6 +302,7 @@ export function HomepageAuth({
setLoggedIn(true); setLoggedIn(true);
setIsAdmin(!!meRes.data.is_admin); setIsAdmin(!!meRes.data.is_admin);
setUsername(meRes.data.username || null); setUsername(meRes.data.username || null);
setUserId(meRes.data.id || null);
setDbError(null); setDbError(null);
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
}) })
@@ -307,6 +312,7 @@ export function HomepageAuth({
setLoggedIn(false); setLoggedIn(false);
setIsAdmin(false); setIsAdmin(false);
setUsername(null); setUsername(null);
setUserId(null);
setCookie("jwt", "", -1); setCookie("jwt", "", -1);
window.history.replaceState({}, document.title, window.location.pathname); window.history.replaceState({}, document.title, window.location.pathname);
}) })

View File

@@ -119,23 +119,37 @@ export function HomepageSidebar({
React.useEffect(() => { React.useEffect(() => {
if (adminSheetOpen) { if (adminSheetOpen) {
API.get("/registration-allowed").then(res => { const jwt = getCookie("jwt");
setAllowRegistration(res.data.allowed); if (jwt && isAdmin) {
}); API.get("/oidc-config").then(res => {
if (res.data) {
API.get("/oidc-config").then(res => { setOidcConfig(res.data);
if (res.data) { }
setOidcConfig(res.data); }).catch((error) => {
} });
}).catch((error) => { fetchUsers();
}); }
fetchUsers();
} else { } 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) => { const handleToggle = async (checked: boolean) => {
if (!isAdmin) {
return;
}
setRegLoading(true); setRegLoading(true);
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
@@ -153,6 +167,11 @@ export function HomepageSidebar({
const handleOIDCConfigSubmit = async (e: React.FormEvent) => { const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!isAdmin) {
return;
}
setOidcLoading(true); setOidcLoading(true);
setOidcError(null); setOidcError(null);
setOidcSuccess(null); setOidcSuccess(null);
@@ -214,8 +233,13 @@ export function HomepageSidebar({
}; };
const fetchUsers = async () => { const fetchUsers = async () => {
setUsersLoading(true);
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
if (!jwt || !isAdmin) {
return;
}
setUsersLoading(true);
try { try {
const response = await API.get("/list", { const response = await API.get("/list", {
headers: {Authorization: `Bearer ${jwt}`} headers: {Authorization: `Bearer ${jwt}`}
@@ -233,6 +257,11 @@ export function HomepageSidebar({
const fetchAdminCount = async () => { const fetchAdminCount = async () => {
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
if (!jwt || !isAdmin) {
return;
}
try { try {
const response = await API.get("/list", { const response = await API.get("/list", {
headers: {Authorization: `Bearer ${jwt}`} headers: {Authorization: `Bearer ${jwt}`}
@@ -248,6 +277,10 @@ export function HomepageSidebar({
e.preventDefault(); e.preventDefault();
if (!newAdminUsername.trim()) return; if (!newAdminUsername.trim()) return;
if (!isAdmin) {
return;
}
setMakeAdminLoading(true); setMakeAdminLoading(true);
setMakeAdminError(null); setMakeAdminError(null);
setMakeAdminSuccess(null); setMakeAdminSuccess(null);
@@ -271,6 +304,10 @@ export function HomepageSidebar({
const removeAdminStatus = async (username: string) => { const removeAdminStatus = async (username: string) => {
if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return; if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return;
if (!isAdmin) {
return;
}
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await API.post("/remove-admin", await API.post("/remove-admin",
@@ -286,6 +323,10 @@ export function HomepageSidebar({
const deleteUser = async (username: string) => { const deleteUser = async (username: string) => {
if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return; if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return;
if (!isAdmin) {
return;
}
const jwt = getCookie("jwt"); const jwt = getCookie("jwt");
try { try {
await API.delete("/delete-user", { await API.delete("/delete-user", {
@@ -375,7 +416,11 @@ export function HomepageSidebar({
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none" className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onSelect={() => setAdminSheetOpen(true)}> onSelect={() => {
if (isAdmin) {
setAdminSheetOpen(true);
}
}}>
<span>Admin Settings</span> <span>Admin Settings</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
@@ -400,9 +445,12 @@ export function HomepageSidebar({
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarFooter> </SidebarFooter>
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */} {/* Admin Settings Sheet */}
{isAdmin && ( {isAdmin && (
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}> <Sheet open={adminSheetOpen && isAdmin} onOpenChange={(open) => {
if (open && !isAdmin) return;
setAdminSheetOpen(open);
}}>
<SheetContent side="left" className="w-[700px] max-h-screen overflow-y-auto"> <SheetContent side="left" className="w-[700px] max-h-screen overflow-y-auto">
<SheetHeader className="px-6 pb-4"> <SheetHeader className="px-6 pb-4">
<SheetTitle>Admin Settings</SheetTitle> <SheetTitle>Admin Settings</SheetTitle>
@@ -510,7 +558,7 @@ export function HomepageSidebar({
id="token_url" id="token_url"
value={oidcConfig.token_url} value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)} 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 required
/> />
</div> </div>

View File

@@ -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 (
<Card className="w-full max-w-2xl mx-auto">
<CardHeader>
<CardTitle className="text-2xl font-bold text-center">
The Future of Termix
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-center leading-relaxed">
Please checkout the linked survey{" "}
<a
href="https://docs.google.com/forms/d/e/1FAIpQLSeGvnQODFtnpjmJsMKgASbaQ87CLQEBCcnzK_Vuw5TdfbfIyA/viewform?usp=sharing&ouid=107601685503825301492"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline hover:text-primary/80 transition-colors"
>
here
</a>
. 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!
</p>
<p className="text-muted-foreground text-center leading-relaxed mt-6">
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 dont speak Chinese, Ill need
to hire someone to help with the translation. If youd like to support me financially, you can do
so{" "}
<a
href="https://github.com/sponsors/LukeGus"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline hover:text-primary/80 transition-colors"
>
here.
</a>
</p>
</CardContent>
<CardFooter className="justify-center">
<Button
variant="outline"
onClick={onHidePermanently}
className="w-full max-w-xs"
>
Hide Permanently
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -96,21 +96,21 @@ interface ConfigEditorShortcut {
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
const sshHostApi = axios.create({ const sshHostApi = axios.create({
baseURL: isLocalhost ? 'http://localhost:8081' : window.location.origin, baseURL: isLocalhost ? 'http://localhost:8081' : '',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
const tunnelApi = axios.create({ const tunnelApi = axios.create({
baseURL: isLocalhost ? 'http://localhost:8083' : window.location.origin, baseURL: isLocalhost ? 'http://localhost:8083' : '',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
const configEditorApi = axios.create({ const configEditorApi = axios.create({
baseURL: isLocalhost ? 'http://localhost:8084' : window.location.origin, baseURL: isLocalhost ? 'http://localhost:8084' : '',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }

View File

@@ -2,6 +2,7 @@ import express from 'express';
import bodyParser from 'body-parser'; import bodyParser from 'body-parser';
import userRoutes from './routes/users.js'; import userRoutes from './routes/users.js';
import sshRoutes from './routes/ssh.js'; import sshRoutes from './routes/ssh.js';
import alertRoutes from './routes/alerts.js';
import chalk from 'chalk'; import chalk from 'chalk';
import cors from 'cors'; import cors from 'cors';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
@@ -227,12 +228,16 @@ app.get('/releases/rss', async (req, res) => {
res.json(response); res.json(response);
} catch (error) { } catch (error) {
logger.error('Failed to generate RSS format', 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('/users', userRoutes);
app.use('/ssh', sshRoutes); app.use('/ssh', sshRoutes);
app.use('/alerts', alertRoutes);
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error('Unhandled error:', err); logger.error('Unhandled error:', err);
@@ -240,4 +245,5 @@ app.use((err: unknown, req: express.Request, res: express.Response, next: expres
}); });
const PORT = 8081; const PORT = 8081;
app.listen(PORT, () => {}); app.listen(PORT, () => {
});

View File

@@ -121,6 +121,14 @@ sqlite.exec(`
FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (host_id) REFERENCES ssh_data(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) => { const addColumnIfNotExists = (table: string, column: string, definition: string) => {

View File

@@ -74,3 +74,10 @@ export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', {
path: text('path').notNull(), path: text('path').notNull(),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), 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`),
});

View File

@@ -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<string, CacheEntry> = 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<TermixAlert[]> {
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;

View File

@@ -464,7 +464,6 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res:
.set({lastOpened: new Date().toISOString()}) .set({lastOpened: new Date().toISOString()})
.where(and(...conditions)); .where(and(...conditions));
} else { } else {
// Add new recent file
await db.insert(configEditorRecent).values({ await db.insert(configEditorRecent).values({
userId, userId,
hostId, 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; export default router;

View File

@@ -571,6 +571,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => {
return res.status(401).json({error: 'User not found'}); return res.status(401).json({error: 'User not found'});
} }
res.json({ res.json({
userId: user[0].id,
username: user[0].username, username: user[0].username,
is_admin: !!user[0].is_admin, is_admin: !!user[0].is_admin,
is_oidc: !!user[0].is_oidc is_oidc: !!user[0].is_oidc