diff --git a/docker/nginx.conf b/docker/nginx.conf index e6a83300..848152f0 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -45,6 +45,15 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location /alerts/ { + proxy_pass http://127.0.0.1:8081; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /ssh/db/ { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; diff --git a/src/apps/Homepage/AlertCard.tsx b/src/apps/Homepage/AlertCard.tsx new file mode 100644 index 00000000..c6850f6e --- /dev/null +++ b/src/apps/Homepage/AlertCard.tsx @@ -0,0 +1,150 @@ +import React from "react"; +import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card"; +import {Button} from "@/components/ui/button"; +import {Badge} from "@/components/ui/badge"; +import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react"; + +interface TermixAlert { + id: string; + title: string; + message: string; + expiresAt: string; + priority?: 'low' | 'medium' | 'high' | 'critical'; + type?: 'info' | 'warning' | 'error' | 'success'; + actionUrl?: string; + actionText?: string; +} + +interface AlertCardProps { + alert: TermixAlert; + onDismiss: (alertId: string) => void; + onClose: () => void; +} + +const getAlertIcon = (type?: string) => { + switch (type) { + case 'warning': + return ; + case 'error': + return ; + case 'success': + return ; + case 'info': + default: + return ; + } +}; + +const getPriorityBadgeVariant = (priority?: string) => { + switch (priority) { + case 'critical': + return 'destructive'; + case 'high': + return 'destructive'; + case 'medium': + return 'secondary'; + case 'low': + default: + return 'outline'; + } +}; + +const getTypeBadgeVariant = (type?: string) => { + switch (type) { + case 'warning': + return 'secondary'; + case 'error': + return 'destructive'; + case 'success': + return 'default'; + case 'info': + default: + return 'outline'; + } +}; + +export function AlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement { + if (!alert) { + return null; + } + + const handleDismiss = () => { + onDismiss(alert.id); + onClose(); + }; + + const formatExpiryDate = (expiryString: string) => { + const expiryDate = new Date(expiryString); + const now = new Date(); + const diffTime = expiryDate.getTime() - now.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays < 0) return 'Expired'; + if (diffDays === 0) return 'Expires today'; + if (diffDays === 1) return 'Expires tomorrow'; + return `Expires in ${diffDays} days`; + }; + + return ( + + +
+
+ {getAlertIcon(alert.type)} + + {alert.title} + +
+ +
+
+ {alert.priority && ( + + {alert.priority.toUpperCase()} + + )} + {alert.type && ( + + {alert.type} + + )} + + {formatExpiryDate(alert.expiresAt)} + +
+
+ +

+ {alert.message} +

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

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

-

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

-
- - - -
- ); -} diff --git a/src/apps/SSH/ssh-axios.ts b/src/apps/SSH/ssh-axios.ts index e2f1eab9..1c7e39d9 100644 --- a/src/apps/SSH/ssh-axios.ts +++ b/src/apps/SSH/ssh-axios.ts @@ -96,21 +96,21 @@ interface ConfigEditorShortcut { const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; const sshHostApi = axios.create({ - baseURL: isLocalhost ? 'http://localhost:8081' : window.location.origin, + baseURL: isLocalhost ? 'http://localhost:8081' : '', headers: { 'Content-Type': 'application/json', }, }); const tunnelApi = axios.create({ - baseURL: isLocalhost ? 'http://localhost:8083' : window.location.origin, + baseURL: isLocalhost ? 'http://localhost:8083' : '', headers: { 'Content-Type': 'application/json', }, }); const configEditorApi = axios.create({ - baseURL: isLocalhost ? 'http://localhost:8084' : window.location.origin, + baseURL: isLocalhost ? 'http://localhost:8084' : '', headers: { 'Content-Type': 'application/json', } diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 40d02983..a59f1ffd 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -2,6 +2,7 @@ import express from 'express'; import bodyParser from 'body-parser'; import userRoutes from './routes/users.js'; import sshRoutes from './routes/ssh.js'; +import alertRoutes from './routes/alerts.js'; import chalk from 'chalk'; import cors from 'cors'; import fetch from 'node-fetch'; @@ -101,10 +102,10 @@ interface GitHubRelease { async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise { const cachedData = githubCache.get(cacheKey); if (cachedData) { - return { - data: cachedData, - cached: true, - cache_age: Date.now() - cachedData.timestamp + return { + data: cachedData, + cached: true, + cache_age: Date.now() - cachedData.timestamp }; } @@ -124,10 +125,10 @@ async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise const data = await response.json(); githubCache.set(cacheKey, data); - - return { - data: data, - cached: false + + return { + data: data, + cached: false }; } catch (error) { logger.error(`Failed to fetch from GitHub API: ${endpoint}`, error); @@ -227,12 +228,16 @@ app.get('/releases/rss', async (req, res) => { res.json(response); } catch (error) { logger.error('Failed to generate RSS format', error) - res.status(500).json({ error: 'Failed to generate RSS format', details: error instanceof Error ? error.message : 'Unknown error' }); + res.status(500).json({ + error: 'Failed to generate RSS format', + details: error instanceof Error ? error.message : 'Unknown error' + }); } }); app.use('/users', userRoutes); app.use('/ssh', sshRoutes); +app.use('/alerts', alertRoutes); app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => { logger.error('Unhandled error:', err); @@ -240,4 +245,5 @@ app.use((err: unknown, req: express.Request, res: express.Response, next: expres }); const PORT = 8081; -app.listen(PORT, () => {}); \ No newline at end of file +app.listen(PORT, () => { +}); \ No newline at end of file diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index ae87ab6d..b41e4729 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -121,6 +121,14 @@ sqlite.exec(` FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (host_id) REFERENCES ssh_data(id) ); + + CREATE TABLE IF NOT EXISTS dismissed_alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + alert_id TEXT NOT NULL, + dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); `); const addColumnIfNotExists = (table: string, column: string, definition: string) => { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 652a5686..b7ecc68f 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -73,4 +73,11 @@ export const configEditorShortcuts = sqliteTable('config_editor_shortcuts', { name: text('name').notNull(), path: text('path').notNull(), createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), +}); + +export const dismissedAlerts = sqliteTable('dismissed_alerts', { + id: integer('id').primaryKey({autoIncrement: true}), + userId: text('user_id').notNull().references(() => users.id), + alertId: text('alert_id').notNull(), + dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`), }); \ No newline at end of file diff --git a/src/backend/database/routes/alerts.ts b/src/backend/database/routes/alerts.ts new file mode 100644 index 00000000..f38cef89 --- /dev/null +++ b/src/backend/database/routes/alerts.ts @@ -0,0 +1,270 @@ +import express from 'express'; +import {db} from '../db/index.js'; +import {dismissedAlerts} from '../db/schema.js'; +import {eq, and} from 'drizzle-orm'; +import chalk from 'chalk'; +import fetch from 'node-fetch'; +import type {Request, Response, NextFunction} from 'express'; + +const dbIconSymbol = '🚨'; +const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`); +const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => { + return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#dc2626')(`[${dbIconSymbol}]`)} ${message}`; +}; +const logger = { + info: (msg: string): void => { + console.log(formatMessage('info', chalk.cyan, msg)); + }, + warn: (msg: string): void => { + console.warn(formatMessage('warn', chalk.yellow, msg)); + }, + error: (msg: string, err?: unknown): void => { + console.error(formatMessage('error', chalk.redBright, msg)); + if (err) console.error(err); + }, + success: (msg: string): void => { + console.log(formatMessage('success', chalk.greenBright, msg)); + }, + debug: (msg: string): void => { + if (process.env.NODE_ENV !== 'production') { + console.debug(formatMessage('debug', chalk.magenta, msg)); + } + } +}; + +interface CacheEntry { + data: any; + timestamp: number; + expiresAt: number; +} + +class AlertCache { + private cache: Map = new Map(); + private readonly CACHE_DURATION = 5 * 60 * 1000; + + set(key: string, data: any): void { + const now = Date.now(); + this.cache.set(key, { + data, + timestamp: now, + expiresAt: now + this.CACHE_DURATION + }); + } + + get(key: string): any | null { + const entry = this.cache.get(key); + if (!entry) { + return null; + } + + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return null; + } + + return entry.data; + } +} + +const alertCache = new AlertCache(); + +const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com'; +const REPO_OWNER = 'LukeGus'; +const REPO_NAME = 'Termix-Docs'; +const ALERTS_FILE = 'main/termix-alerts.json'; + +interface TermixAlert { + id: string; + title: string; + message: string; + expiresAt: string; + priority?: 'low' | 'medium' | 'high' | 'critical'; + type?: 'info' | 'warning' | 'error' | 'success'; + actionUrl?: string; + actionText?: string; +} + +async function fetchAlertsFromGitHub(): Promise { + const cacheKey = 'termix_alerts'; + const cachedData = alertCache.get(cacheKey); + if (cachedData) { + return cachedData; + } + + try { + const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`; + + const response = await fetch(url, { + headers: { + 'Accept': 'application/json', + 'User-Agent': 'TermixAlertChecker/1.0' + } + }); + + if (!response.ok) { + throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`); + } + + const alerts: TermixAlert[] = await response.json() as TermixAlert[]; + + const now = new Date(); + + const validAlerts = alerts.filter(alert => { + const expiryDate = new Date(alert.expiresAt); + const isValid = expiryDate > now; + return isValid; + }); + + alertCache.set(cacheKey, validAlerts); + return validAlerts; + } catch (error) { + logger.error('Failed to fetch alerts from GitHub', error); + return []; + } +} + +const router = express.Router(); + +// Route: Get all active alerts +// GET /alerts +router.get('/', async (req, res) => { + try { + const alerts = await fetchAlertsFromGitHub(); + res.json({ + alerts, + cached: alertCache.get('termix_alerts') !== null, + total_count: alerts.length + }); + } catch (error) { + logger.error('Failed to get alerts', error); + res.status(500).json({error: 'Failed to fetch alerts'}); + } +}); + +// Route: Get alerts for a specific user (excluding dismissed ones) +// GET /alerts/user/:userId +router.get('/user/:userId', async (req, res) => { + try { + const {userId} = req.params; + + if (!userId) { + return res.status(400).json({error: 'User ID is required'}); + } + + const allAlerts = await fetchAlertsFromGitHub(); + + const dismissedAlertRecords = await db + .select({alertId: dismissedAlerts.alertId}) + .from(dismissedAlerts) + .where(eq(dismissedAlerts.userId, userId)); + + const dismissedAlertIds = new Set(dismissedAlertRecords.map(record => record.alertId)); + + const userAlerts = allAlerts.filter(alert => !dismissedAlertIds.has(alert.id)); + + res.json({ + alerts: userAlerts, + total_count: userAlerts.length, + dismissed_count: dismissedAlertIds.size + }); + } catch (error) { + logger.error('Failed to get user alerts', error); + res.status(500).json({error: 'Failed to fetch user alerts'}); + } +}); + +// Route: Dismiss an alert for a user +// POST /alerts/dismiss +router.post('/dismiss', async (req, res) => { + try { + const {userId, alertId} = req.body; + + if (!userId || !alertId) { + logger.warn('Missing userId or alertId in dismiss request'); + return res.status(400).json({error: 'User ID and Alert ID are required'}); + } + + const existingDismissal = await db + .select() + .from(dismissedAlerts) + .where(and( + eq(dismissedAlerts.userId, userId), + eq(dismissedAlerts.alertId, alertId) + )); + + if (existingDismissal.length > 0) { + logger.warn(`Alert ${alertId} already dismissed by user ${userId}`); + return res.status(409).json({error: 'Alert already dismissed'}); + } + + const result = await db.insert(dismissedAlerts).values({ + userId, + alertId + }); + + logger.success(`Alert ${alertId} dismissed by user ${userId}. Insert result: ${JSON.stringify(result)}`); + res.json({message: 'Alert dismissed successfully'}); + } catch (error) { + logger.error('Failed to dismiss alert', error); + res.status(500).json({error: 'Failed to dismiss alert'}); + } +}); + +// Route: Get dismissed alerts for a user +// GET /alerts/dismissed/:userId +router.get('/dismissed/:userId', async (req, res) => { + try { + const {userId} = req.params; + + if (!userId) { + return res.status(400).json({error: 'User ID is required'}); + } + + const dismissedAlertRecords = await db + .select({ + alertId: dismissedAlerts.alertId, + dismissedAt: dismissedAlerts.dismissedAt + }) + .from(dismissedAlerts) + .where(eq(dismissedAlerts.userId, userId)); + + res.json({ + dismissed_alerts: dismissedAlertRecords, + total_count: dismissedAlertRecords.length + }); + } catch (error) { + logger.error('Failed to get dismissed alerts', error); + res.status(500).json({error: 'Failed to fetch dismissed alerts'}); + } +}); + +// Route: Undismiss an alert for a user (remove from dismissed list) +// DELETE /alerts/dismiss +router.delete('/dismiss', async (req, res) => { + try { + const {userId, alertId} = req.body; + + if (!userId || !alertId) { + return res.status(400).json({error: 'User ID and Alert ID are required'}); + } + + const result = await db + .delete(dismissedAlerts) + .where(and( + eq(dismissedAlerts.userId, userId), + eq(dismissedAlerts.alertId, alertId) + )); + + if (result.changes === 0) { + return res.status(404).json({error: 'Dismissed alert not found'}); + } + + logger.success(`Alert ${alertId} undismissed by user ${userId}`); + res.json({message: 'Alert undismissed successfully'}); + } catch (error) { + logger.error('Failed to undismiss alert', error); + res.status(500).json({error: 'Failed to undismiss alert'}); + } +}); + +export default router; diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 819af76a..18b8416a 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -464,7 +464,6 @@ router.post('/config_editor/recent', authenticateJWT, async (req: Request, res: .set({lastOpened: new Date().toISOString()}) .where(and(...conditions)); } else { - // Add new recent file await db.insert(configEditorRecent).values({ userId, hostId, @@ -692,117 +691,4 @@ router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, } }); -// Route: Bulk import SSH hosts from JSON (requires JWT) -// POST /ssh/bulk-import -router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as any).userId; - const {hosts} = req.body; - - if (!Array.isArray(hosts) || hosts.length === 0) { - logger.warn('Invalid bulk import data - hosts array is required and must not be empty'); - return res.status(400).json({error: 'Hosts array is required and must not be empty'}); - } - - if (hosts.length > 100) { - logger.warn(`Bulk import attempted with too many hosts: ${hosts.length}`); - return res.status(400).json({error: 'Maximum 100 hosts allowed per import'}); - } - - const results = { - success: 0, - failed: 0, - errors: [] as string[] - }; - - for (let i = 0; i < hosts.length; i++) { - const hostData = hosts[i]; - - try { - if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) { - results.failed++; - results.errors.push(`Host ${i + 1}: Missing or invalid required fields (ip, port, username)`); - continue; - } - - if (hostData.authType !== 'password' && hostData.authType !== 'key') { - results.failed++; - results.errors.push(`Host ${i + 1}: Invalid authType. Must be 'password' or 'key'`); - continue; - } - - if (hostData.authType === 'password' && !isNonEmptyString(hostData.password)) { - results.failed++; - results.errors.push(`Host ${i + 1}: Password required for password authentication`); - continue; - } - - if (hostData.authType === 'key' && !isNonEmptyString(hostData.key)) { - results.failed++; - results.errors.push(`Host ${i + 1}: SSH key required for key authentication`); - continue; - } - - // Validate tunnel connections if enabled - if (hostData.enableTunnel && Array.isArray(hostData.tunnelConnections)) { - for (let j = 0; j < hostData.tunnelConnections.length; j++) { - const conn = hostData.tunnelConnections[j]; - if (!isValidPort(conn.sourcePort) || !isValidPort(conn.endpointPort) || !isNonEmptyString(conn.endpointHost)) { - results.failed++; - results.errors.push(`Host ${i + 1}, Tunnel ${j + 1}: Invalid tunnel connection data`); - break; - } - } - } - - const sshDataObj: any = { - userId: userId, - name: hostData.name || '', - folder: hostData.folder || '', - tags: Array.isArray(hostData.tags) ? hostData.tags.join(',') : (hostData.tags || ''), - ip: hostData.ip, - port: hostData.port, - username: hostData.username, - authType: hostData.authType, - pin: !!hostData.pin ? 1 : 0, - enableTerminal: !!hostData.enableTerminal ? 1 : 0, - enableTunnel: !!hostData.enableTunnel ? 1 : 0, - tunnelConnections: Array.isArray(hostData.tunnelConnections) ? JSON.stringify(hostData.tunnelConnections) : null, - enableConfigEditor: !!hostData.enableConfigEditor ? 1 : 0, - defaultPath: hostData.defaultPath || null, - }; - - if (hostData.authType === 'password') { - sshDataObj.password = hostData.password; - sshDataObj.key = null; - sshDataObj.keyPassword = null; - sshDataObj.keyType = null; - } else if (hostData.authType === 'key') { - sshDataObj.key = hostData.key; - sshDataObj.keyPassword = hostData.keyPassword || null; - sshDataObj.keyType = hostData.keyType || null; - sshDataObj.password = null; - } - - await db.insert(sshData).values(sshDataObj); - results.success++; - - } catch (err) { - results.failed++; - results.errors.push(`Host ${i + 1}: ${err instanceof Error ? err.message : 'Unknown error'}`); - logger.error(`Failed to import host ${i + 1}:`, err); - } - } - - if (results.success > 0) { - logger.success(`Bulk import completed: ${results.success} successful, ${results.failed} failed`); - } else { - logger.warn(`Bulk import failed: ${results.failed} failed`); - } - - res.json({ - message: `Import completed: ${results.success} successful, ${results.failed} failed`, - ...results - }); -}); - export default router; \ No newline at end of file diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index ac7c3dd9..98985d5e 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -571,6 +571,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => { return res.status(401).json({error: 'User not found'}); } res.json({ + userId: user[0].id, username: user[0].username, is_admin: !!user[0].is_admin, is_oidc: !!user[0].is_oidc