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:
40
.github/dependabot.yml
vendored
Normal file
40
.github/dependabot.yml
vendored
Normal 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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
150
src/apps/Homepage/AlertCard.tsx
Normal file
150
src/apps/Homepage/AlertCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
src/apps/Homepage/AlertManager.tsx
Normal file
202
src/apps/Homepage/AlertManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 don’t speak Chinese, I’ll need
|
||||
to hire someone to help with the translation. If you’d like to support me financially, you can do
|
||||
so{" "}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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, () => {
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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`),
|
||||
});
|
||||
270
src/backend/database/routes/alerts.ts
Normal file
270
src/backend/database/routes/alerts.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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
114
src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user