Merge pull request #70 from LukeGus/dev-1.2

Dev 1.2
This commit was merged in pull request #70.
This commit is contained in:
Karmaa
2025-08-13 12:01:30 -05:00
committed by GitHub
21 changed files with 2769 additions and 365 deletions

40
.github/dependabot.yml vendored Normal file
View File

@@ -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"

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -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:

View File

@@ -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"]
CMD ["/entrypoint.sh"]

View File

@@ -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;

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 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<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [authLoading, setAuthLoading] = useState(true);
const [dbError, setDbError] = useState<string | null>(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 (
<HomepageSidebar
onSelectView={onSelectView}
@@ -87,6 +82,7 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin}
setUsername={setUsername}
setUserId={setUserId}
loggedIn={loggedIn}
authLoading={authLoading}
dbError={dbError}
@@ -97,12 +93,11 @@ export function Homepage({onSelectView}: HomepageProps): React.ReactElement {
/>
</div>
{loggedIn && !authLoading && showWelcomeCard && (
<div
className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-10">
<HomepageWelcomeCard onHidePermanently={handleHideWelcomeCard}/>
</div>
)}
{/* Alert Manager - replaces the old welcome card */}
<AlertManager
userId={userId}
loggedIn={loggedIn}
/>
</div>
</HomepageSidebar>
);

View File

@@ -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<string | null>(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({
<h2 className="text-xl font-bold mb-1">
{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"}
</h2>
</div>
{tab === "external" ? (
{tab === "external" || tab === "reset" ? (
<div className="flex flex-col gap-5">
<div className="text-center text-muted-foreground mb-4">
<p>Login using your configured external identity provider</p>
</div>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : "Login with External Provider"}
</Button>
{tab === "external" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Login using your configured external identity provider</p>
</div>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : "Login with External Provider"}
</Button>
</>
)}
{tab === "reset" && (
<>
{resetStep === "initiate" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter your username to receive a password reset code. The code
will be logged in the docker container logs.</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">Username</Label>
<Input
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={resetLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
onClick={initiatePasswordReset}
>
{resetLoading ? Spinner : "Send Reset Code"}
</Button>
</div>
</>
)}
{resetStep === "verify" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter the 6-digit code from the docker container logs for
user: <strong>{localUsername}</strong></p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">Reset Code</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || resetCode.length !== 6}
onClick={verifyResetCode}
>
{resetLoading ? Spinner : "Verify Code"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
Back
</Button>
</div>
</>
)}
{resetSuccess && (
<>
<Alert className="mb-4">
<AlertTitle>Success!</AlertTitle>
<AlertDescription>
Your password has been successfully reset! You can now log in
with your new password.
</AlertDescription>
</Alert>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
onClick={() => {
setTab("login");
resetPasswordState();
}}
>
Go to Login
</Button>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter your new password for
user: <strong>{localUsername}</strong></p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
required
className="h-11 text-base"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
disabled={resetLoading}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Input
id="confirm-password"
type="password"
required
className="h-11 text-base"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
disabled={resetLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !newPassword || !confirmPassword}
onClick={completePasswordReset}
>
{resetLoading ? Spinner : "Reset Password"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
Back
</Button>
</div>
</>
)}
</>
)}
</div>
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
@@ -380,10 +680,33 @@ export function HomepageAuth({
value={password} onChange={e => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">Confirm Password</Label>
<Input id="signup-confirm-password" type="password" required
className="h-11 text-base"
value={signupConfirmPassword}
onChange={e => setSignupConfirmPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div>
)}
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}>
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
</Button>
{tab === "login" && (
<Button type="button" variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={loading || internalLoggedIn}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
>
Reset Password
</Button>
)}
</form>
)}
</>

View File

@@ -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<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
const [deletePassword, setDeletePassword] = React.useState("");
const [deleteLoading, setDeleteLoading] = React.useState(false);
const [deleteError, setDeleteError] = React.useState<string | null>(null);
const [adminCount, setAdminCount] = React.useState(0);
const [users, setUsers] = React.useState<Array<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
}>>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(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 (
<div className="min-h-svh">
<SidebarProvider>
@@ -201,7 +382,8 @@ export function HomepageSidebar({
</SidebarMenuItem>
</div>
<SidebarMenuItem key={"Tools"}>
<SidebarMenuButton onClick={() => window.open("https://dashix.dev", "_blank")} disabled={disabled}>
<SidebarMenuButton onClick={() => window.open("https://dashix.dev", "_blank")}
disabled={disabled}>
<Hammer/>
<span>Tools</span>
</SidebarMenuButton>
@@ -234,7 +416,11 @@ export function HomepageSidebar({
{isAdmin && (
<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"
onSelect={() => setAdminSheetOpen(true)}>
onSelect={() => {
if (isAdmin) {
setAdminSheetOpen(true);
}
}}>
<span>Admin Settings</span>
</DropdownMenuItem>
)}
@@ -243,183 +429,379 @@ export function HomepageSidebar({
onSelect={handleLogout}>
<span>Sign out</span>
</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"
onSelect={() => setDeleteAccountOpen(true)}
disabled={isAdmin && adminCount <= 1}
>
<span
className={isAdmin && adminCount <= 1 ? "text-muted-foreground" : "text-red-400"}>
Delete Account
{isAdmin && adminCount <= 1 && " (Last Admin)"}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */}
{/* Admin Settings Sheet */}
{isAdmin && (
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}>
<SheetContent side="left" className="w-[400px] max-h-screen overflow-y-auto">
<SheetHeader>
<Sheet open={adminSheetOpen && isAdmin} onOpenChange={(open) => {
if (open && !isAdmin) return;
setAdminSheetOpen(open);
}}>
<SheetContent side="left" className="w-[700px] max-h-screen overflow-y-auto">
<SheetHeader className="px-6 pb-4">
<SheetTitle>Admin Settings</SheetTitle>
</SheetHeader>
<div className="pt-1 pb-4 px-4 flex flex-col gap-6">
{/* Registration Settings */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
disabled={regLoading}/>
Allow new account registration
</label>
</div>
<Separator className="p-0.25 mt-2 mb-2"/>
{/* OIDC Configuration */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
<p className="text-sm text-muted-foreground">
Configure external identity provider for OIDC/OAuth2 authentication.
Users will see an "External" login option once configured.
</p>
{oidcError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label>
<Input
id="client_id"
value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label>
<Input
id="client_secret"
type="password"
value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret"
required
/>
</div>
<div className="px-6">
<Tabs defaultValue="registration" className="w-full">
<TabsList className="grid w-full grid-cols-4 mb-6">
<TabsTrigger value="registration" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
Reg
</TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
OIDC
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
Users
</TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
Admins
</TabsTrigger>
</TabsList>
<div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label>
<Input
id="authorization_url"
value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label>
<Input
id="issuer_url"
value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label>
<Input
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/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label>
<Input
id="identifier_path"
value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract user ID from JWT (e.g., "sub", "email", "preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label>
<Input
id="name_path"
value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract display name from JWT (e.g., "name", "preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label>
<Input
id="scopes"
value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
placeholder="openid email profile"
required
/>
<p className="text-xs text-muted-foreground">
Space-separated list of OAuth2 scopes to request
</p>
</div>
<div className="flex gap-2">
<Button
type="submit"
className="flex-1"
disabled={oidcLoading}
>
{oidcLoading ? "Saving..." : "Save Configuration"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
}}
>
Reset
</Button>
{/* Registration Settings Tab */}
<TabsContent value="registration" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
disabled={regLoading}/>
Allow new account registration
</label>
</div>
</TabsContent>
{oidcSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
{/* OIDC Configuration Tab */}
<TabsContent value="oidc" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication
(OIDC)</h3>
<p className="text-sm text-muted-foreground">
Configure external identity provider for OIDC/OAuth2 authentication.
Users will see an "External" login option once configured.
</p>
{oidcError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label>
<Input
id="client_id"
value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label>
<Input
id="client_secret"
type="password"
value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label>
<Input
id="authorization_url"
value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label>
<Input
id="issuer_url"
value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label>
<Input
id="token_url"
value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder="https://your-provider.com/application/o/token/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label>
<Input
id="identifier_path"
value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract user ID from JWT (e.g., "sub", "email",
"preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label>
<Input
id="name_path"
value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract display name from JWT (e.g., "name",
"preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label>
<Input
id="scopes"
value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
placeholder="openid email profile"
required
/>
<p className="text-xs text-muted-foreground">
Space-separated list of OAuth2 scopes to request
</p>
</div>
<div className="flex gap-2 pt-2">
<Button
type="submit"
className="flex-1"
disabled={oidcLoading}
>
{oidcLoading ? "Saving..." : "Save Configuration"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
}}
>
Reset
</Button>
</div>
{oidcSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
</TabsContent>
{/* Users Management Tab */}
<TabsContent value="users" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">User Management</h3>
<Button
onClick={fetchUsers}
disabled={usersLoading}
variant="outline"
size="sm"
>
{usersLoading ? "Loading..." : "Refresh"}
</Button>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">
Loading users...
</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="px-4 font-medium">
{user.username}
{user.is_admin && (
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
Admin
</span>
)}
</TableCell>
<TableCell className="px-4">
{user.is_oidc ? "External" : "Local"}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() => deleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}
>
<Trash2 className="h-4 w-4"/>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
{/* Admins Management Tab */}
<TabsContent value="admins" className="space-y-6">
<div className="space-y-6">
<h3 className="text-lg font-semibold">Admin Management</h3>
{/* Add New Admin Form */}
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
<h4 className="font-medium">Make User Admin</h4>
<form onSubmit={makeUserAdmin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-admin-username">Username</Label>
<div className="flex gap-2">
<Input
id="new-admin-username"
value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder="Enter username to make admin"
required
/>
<Button
type="submit"
disabled={makeAdminLoading || !newAdminUsername.trim()}
>
{makeAdminLoading ? "Adding..." : "Make Admin"}
</Button>
</div>
</div>
{makeAdminError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{makeAdminError}</AlertDescription>
</Alert>
)}
{makeAdminSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{makeAdminSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
{/* Current Admins Table */}
<div className="space-y-4">
<h4 className="font-medium">Current Admins</h4>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.filter(user => user.is_admin).map((admin) => (
<TableRow key={admin.id}>
<TableCell className="px-4 font-medium">
{admin.username}
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
Admin
</span>
</TableCell>
<TableCell className="px-4">
{admin.is_oidc ? "External" : "Local"}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() => removeAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
disabled={admin.username === username}
>
<Shield className="h-4 w-4"/>
Remove Admin
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
<SheetFooter className="px-4 pt-1 pb-4">
<SheetFooter className="px-6 pt-6 pb-6">
<Separator className="p-0.25 mt-2 mb-2"/>
<SheetClose asChild>
<Button variant="outline">Close</Button>
@@ -428,6 +810,84 @@ export function HomepageSidebar({
</SheetContent>
</Sheet>
)}
{/* Delete Account Confirmation Sheet */}
<Sheet open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<SheetContent side="left" className="w-[400px]">
<SheetHeader className="pb-0">
<SheetTitle>Delete Account</SheetTitle>
<SheetDescription>
This action cannot be undone. This will permanently delete your account and all
associated data.
</SheetDescription>
</SheetHeader>
<div className="pb-4 px-4 flex flex-col gap-4">
<Alert variant="destructive">
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
Deleting your account will remove all your data including SSH hosts,
configurations, and settings.
This action is irreversible.
</AlertDescription>
</Alert>
{deleteError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{deleteError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleDeleteAccount} className="space-y-4">
{isAdmin && adminCount <= 1 && (
<Alert variant="destructive">
<AlertTitle>Cannot Delete Account</AlertTitle>
<AlertDescription>
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.
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="delete-password">Confirm Password</Label>
<Input
id="delete-password"
type="password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
placeholder="Enter your password to confirm"
required
disabled={isAdmin && adminCount <= 1}
/>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={deleteLoading || !deletePassword.trim() || (isAdmin && adminCount <= 1)}
>
{deleteLoading ? "Deleting..." : "Delete Account"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
>
Cancel
</Button>
</div>
</form>
</div>
</SheetContent>
</Sheet>
</Sidebar>
<SidebarInset>
{children}

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

@@ -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<string | null>(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<HTMLInputElement>) => {
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
</p>
</div>
<Button onClick={fetchHosts} variant="outline" size="sm">
Refresh
</Button>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="relative"
onClick={() => document.getElementById('json-import-input')?.click()}
disabled={importing}
>
{importing ? 'Importing...' : 'Import JSON'}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom"
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg">
<div className="space-y-2">
<p className="font-semibold text-sm">Import SSH Hosts from JSON</p>
<p className="text-xs text-muted-foreground">
Upload a JSON file to bulk import multiple SSH hosts (max 100).
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
onClick={() => {
const sampleData = {
hosts: [
{
name: "Web Server - Production",
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: "Production",
tags: ["web", "production", "nginx"],
pin: true,
enableTerminal: true,
enableTunnel: false,
enableConfigEditor: true,
defaultPath: "/var/www"
},
{
name: "Database Server",
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
authType: "key",
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: "Production",
tags: ["database", "production", "postgresql"],
pin: false,
enableTerminal: true,
enableTunnel: true,
enableConfigEditor: false,
tunnelConnections: [
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: "Web Server - Production",
maxRetries: 3,
retryInterval: 10,
autoStart: true
}
]
}
]
};
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sample-ssh-hosts.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}}
>
Download Sample
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const infoContent = `
JSON Import Format Guide
REQUIRED FIELDS:
• ip: Host IP address (string)
• 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 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"
}
]
}
• 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
`;
const newWindow = window.open('', '_blank', 'width=600,height=800,scrollbars=yes,resizable=yes');
if (newWindow) {
newWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>SSH JSON Import Guide</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 20px;
background: #1a1a1a;
color: #ffffff;
line-height: 1.6;
}
pre {
background: #2a2a2a;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
border: 1px solid #404040;
}
code {
background: #404040;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
}
h1 { color: #60a5fa; border-bottom: 2px solid #60a5fa; padding-bottom: 10px; }
h2 { color: #34d399; margin-top: 25px; }
.field-group { margin: 15px 0; }
.field-item { margin: 8px 0; }
.copy-btn {
background: #3b82f6;
color: white;
border: none;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
margin-left: 10px;
}
.copy-btn:hover { background: #2563eb; }
</style>
</head>
<body>
<h1>SSH JSON Import Format Guide</h1>
<p>Use this guide to create JSON files for bulk importing SSH hosts. All examples are copyable.</p>
<h2>Required Fields</h2>
<div class="field-group">
<div class="field-item">
<code>ip</code> - Host IP address (string)
<button class="copy-btn" onclick="navigator.clipboard.writeText('ip')">Copy</button>
</div>
<div class="field-item">
<code>port</code> - SSH port (number, 1-65535)
<button class="copy-btn" onclick="navigator.clipboard.writeText('port')">Copy</button>
</div>
<div class="field-item">
<code>username</code> - SSH username (string)
<button class="copy-btn" onclick="navigator.clipboard.writeText('username')">Copy</button>
</div>
<div class="field-item">
<code>authType</code> - "password" or "key"
<button class="copy-btn" onclick="navigator.clipboard.writeText('authType')">Copy</button>
</div>
</div>
<h2>Authentication Fields</h2>
<div class="field-group">
<div class="field-item">
<code>password</code> - Required if authType is "password"
<button class="copy-btn" onclick="navigator.clipboard.writeText('password')">Copy</button>
</div>
<div class="field-item">
<code>key</code> - SSH private key content (string) if authType is "key"
<button class="copy-btn" onclick="navigator.clipboard.writeText('key')">Copy</button>
</div>
<div class="field-item">
<code>keyPassword</code> - Optional key passphrase
<button class="copy-btn" onclick="navigator.clipboard.writeText('keyPassword')">Copy</button>
</div>
<div class="field-item">
<code>keyType</code> - Key type (auto, ssh-rsa, ssh-ed25519, etc.)
<button class="copy-btn" onclick="navigator.clipboard.writeText('keyType')">Copy</button>
</div>
</div>
<h2>Optional Fields</h2>
<div class="field-group">
<div class="field-item">
<code>name</code> - Display name (string)
<button class="copy-btn" onclick="navigator.clipboard.writeText('name')">Copy</button>
</div>
<div class="field-item">
<code>folder</code> - Organization folder (string)
<button class="copy-btn" onclick="navigator.clipboard.writeText('folder')">Copy</button>
</div>
<div class="field-item">
<code>tags</code> - Array of tag strings
<button class="copy-btn" onclick="navigator.clipboard.writeText('tags')">Copy</button>
</div>
<div class="field-item">
<code>pin</code> - Pin to top (boolean)
<button class="copy-btn" onclick="navigator.clipboard.writeText('pin')">Copy</button>
</div>
<div class="field-item">
<code>enableTerminal</code> - Show in Terminal tab (boolean, default: true)
<button class="copy-btn" onclick="navigator.clipboard.writeText('enableTerminal')">Copy</button>
</div>
<div class="field-item">
<code>enableTunnel</code> - Show in Tunnel tab (boolean, default: true)
<button class="copy-btn" onclick="navigator.clipboard.writeText('enableTunnel')">Copy</button>
</div>
<div class="field-item">
<code>enableConfigEditor</code> - Show in Config Editor tab (boolean, default: true)
<button class="copy-btn" onclick="navigator.clipboard.writeText('enableConfigEditor')">Copy</button>
</div>
<div class="field-item">
<code>defaultPath</code> - Default directory path (string)
<button class="copy-btn" onclick="navigator.clipboard.writeText('defaultPath')">Copy</button>
</div>
</div>
<h2>Tunnel Configuration</h2>
<div class="field-group">
<div class="field-item">
<code>tunnelConnections</code> - Array of tunnel objects
<button class="copy-btn" onclick="navigator.clipboard.writeText('tunnelConnections')">Copy</button>
</div>
<div style="margin-left: 20px;">
<div class="field-item">
<code>sourcePort</code> - Local port (number)
<button class="copy-btn" onclick="navigator.clipboard.writeText('sourcePort')">Copy</button>
</div>
<div class="field-item">
<code>endpointPort</code> - Remote port (number)
<button class="copy-btn" onclick="navigator.clipboard.writeText('endpointPort')">Copy</button>
</div>
<div class="field-item">
<code>endpointHost</code> - Target host name (string)
<button class="copy-btn" onclick="navigator.clipboard.writeText('endpointHost')">Copy</button>
</div>
<div class="field-item">
<code>maxRetries</code> - Retry attempts (number, default: 3)
<button class="copy-btn" onclick="navigator.clipboard.writeText('maxRetries')">Copy</button>
</div>
<div class="field-item">
<code>retryInterval</code> - Retry delay in seconds (number, default: 10)
<button class="copy-btn" onclick="navigator.clipboard.writeText('retryInterval')">Copy</button>
</div>
<div class="field-item">
<code>autoStart</code> - Auto-start on launch (boolean, default: false)
<button class="copy-btn" onclick="navigator.clipboard.writeText('autoStart')">Copy</button>
</div>
</div>
</div>
<h2>Example JSON Structure</h2>
<pre><code>{
"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"
}
]
}</code></pre>
<h2>Important Notes</h2>
<ul>
<li>Maximum 100 hosts per import</li>
<li>File should contain a "hosts" array or be an array of host objects</li>
<li>All fields are copyable for easy reference</li>
<li>Use the Download Sample button to get a complete example file</li>
</ul>
</body>
</html>
`);
newWindow.document.close();
}
}}
>
Format Guide
</Button>
<div className="w-px h-6 bg-border mx-2" />
<Button onClick={fetchHosts} variant="outline" size="sm">
Refresh
</Button>
</div>
</div>
<input
id="json-import-input"
type="file"
accept=".json"
onChange={handleJsonImport}
style={{display: 'none'}}
/>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input

View File

@@ -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',
}
@@ -262,6 +262,20 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
}
}
export async function bulkImportSSHHosts(hosts: SSHHostData[]): Promise<{
message: string;
success: number;
failed: number;
errors: string[];
}> {
try {
const response = await sshHostApi.post('/ssh/bulk-import', {hosts});
return response.data;
} catch (error) {
throw error;
}
}
export async function deleteSSHHost(hostId: number): Promise<any> {
try {
const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`);

View File

@@ -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<any> {
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<any>
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, () => {});
app.listen(PORT, () => {
});

View File

@@ -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) => {

View File

@@ -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`),
});

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

@@ -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;

View File

@@ -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;

114
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -52,7 +52,6 @@ function TooltipContent({
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)