From 602f21b475b6c1e194eb0412ae2d23198ea3e451 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 12 Aug 2025 13:15:08 -0500 Subject: [PATCH] Confirm and hide password, reset password, delete accounts, better admin page, json import hosts. --- README.md | 1 + src/apps/Homepage/HomepageAuth.tsx | 351 ++++++++- src/apps/Homepage/HomepageSidebar.tsx | 744 ++++++++++++++---- src/apps/SSH/Manager/SSHManagerHostViewer.tsx | 412 +++++++++- src/apps/SSH/ssh-axios.ts | 14 + src/backend/database/routes/ssh.ts | 118 ++- src/backend/database/routes/users.ts | 445 +++++++++-- src/components/ui/table.tsx | 114 +++ src/components/ui/tooltip.tsx | 1 - 9 files changed, 1957 insertions(+), 243 deletions(-) create mode 100644 src/components/ui/table.tsx diff --git a/README.md b/README.md index 64243c8f..fbdb385c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Termix is an open-source, forever-free, self-hosted all-in-one server management - **Theming** - Modify themeing for all tools - **Improved SFTP Support** - Ability to manage files easier with the config editor by uploading, creating, and removing files - **Improved Terminal Support** - Add more terminal protocols such as VNC and RDP (anyone who has experience in integrating RDP into a web-application similar to Apache Guacamole, please contact me by creating an issue) +- **Mobile Support** - Support a mobile app or version of the Termix website to manage servers from your phone # Installation Visit the Termix [Docs](https://docs.termix.site/docs) for more information on how to install Termix. Otherwise, view a sample docker-compose file here: diff --git a/src/apps/Homepage/HomepageAuth.tsx b/src/apps/Homepage/HomepageAuth.tsx index 3ad1b24a..30d3a7ea 100644 --- a/src/apps/Homepage/HomepageAuth.tsx +++ b/src/apps/Homepage/HomepageAuth.tsx @@ -46,9 +46,10 @@ export function HomepageAuth({ setDbError, ...props }: HomepageAuthProps) { - const [tab, setTab] = useState<"login" | "signup" | "external">("login"); + const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login"); const [localUsername, setLocalUsername] = useState(""); const [password, setPassword] = useState(""); + const [signupConfirmPassword, setSignupConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); const [oidcLoading, setOidcLoading] = useState(false); const [error, setError] = useState(null); @@ -57,6 +58,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 +110,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}); } @@ -119,6 +145,9 @@ export function HomepageAuth({ setIsAdmin(!!meRes.data.is_admin); setUsername(meRes.data.username || null); setDbError(null); + if (tab === "signup") { + setSignupConfirmPassword(""); + } } catch (err: any) { setError(err?.response?.data?.error || "Unknown error"); setInternalLoggedIn(false); @@ -136,6 +165,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); @@ -301,7 +421,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 +439,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 +458,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 +474,185 @@ export function HomepageAuth({

{tab === "login" ? "Login to your account" : tab === "signup" ? "Create a new account" : - "Login with external provider"} + tab === "external" ? "Login with external provider" : + "Reset your password"}

- {tab === "external" ? ( + {tab === "external" || tab === "reset" ? (
-
-

Login using your configured external identity provider

-
- + {tab === "external" && ( + <> +
+

Login using your configured external identity provider

+
+ + + )} + {tab === "reset" && ( + <> + {resetStep === "initiate" && ( + <> +
+

Enter your username to receive a password reset code. The code + will be logged in the docker container logs.

+
+
+
+ + setLocalUsername(e.target.value)} + disabled={resetLoading} + /> +
+ +
+ + )} + + {resetStep === "verify" && ( + <> +
+

Enter the 6-digit code from the docker container logs for + user: {localUsername}

+
+
+
+ + setResetCode(e.target.value.replace(/\D/g, ''))} + disabled={resetLoading} + placeholder="000000" + /> +
+ + +
+ + )} + + {resetSuccess && ( + <> + + Success! + + Your password has been successfully reset! You can now log in + with your new password. + + + + + )} + + {resetStep === "newPassword" && !resetSuccess && ( + <> +
+

Enter your new password for + user: {localUsername}

+
+
+
+ + setNewPassword(e.target.value)} + disabled={resetLoading} + /> +
+
+ + setConfirmPassword(e.target.value)} + disabled={resetLoading} + /> +
+ + +
+ + )} + + )}
) : (
@@ -380,10 +674,33 @@ export function HomepageAuth({ value={password} onChange={e => setPassword(e.target.value)} disabled={loading || internalLoggedIn}/> + {tab === "signup" && ( +
+ + setSignupConfirmPassword(e.target.value)} + disabled={loading || internalLoggedIn}/> +
+ )} + {tab === "login" && ( + + )}
)} diff --git a/src/apps/Homepage/HomepageSidebar.tsx b/src/apps/Homepage/HomepageSidebar.tsx index ec2af144..ec0df53c 100644 --- a/src/apps/Homepage/HomepageSidebar.tsx +++ b/src/apps/Homepage/HomepageSidebar.tsx @@ -3,7 +3,7 @@ import { Computer, Server, File, - Hammer, ChevronUp, User2, HardDrive + Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings } from "lucide-react"; import { @@ -36,6 +36,15 @@ import {Input} from "@/components/ui/input.tsx"; import {Label} from "@/components/ui/label.tsx"; import {Button} from "@/components/ui/button.tsx"; import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx"; +import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx"; import axios from "axios"; interface SidebarProps { @@ -90,6 +99,24 @@ export function HomepageSidebar({ const [oidcError, setOidcError] = React.useState(null); const [oidcSuccess, setOidcSuccess] = React.useState(null); + const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false); + const [deletePassword, setDeletePassword] = React.useState(""); + const [deleteLoading, setDeleteLoading] = React.useState(false); + const [deleteError, setDeleteError] = React.useState(null); + const [adminCount, setAdminCount] = React.useState(0); + + const [users, setUsers] = React.useState>([]); + const [usersLoading, setUsersLoading] = React.useState(false); + const [newAdminUsername, setNewAdminUsername] = React.useState(""); + const [makeAdminLoading, setMakeAdminLoading] = React.useState(false); + const [makeAdminError, setMakeAdminError] = React.useState(null); + const [makeAdminSuccess, setMakeAdminSuccess] = React.useState(null); + React.useEffect(() => { if (adminSheetOpen) { API.get("/registration-allowed").then(res => { @@ -102,6 +129,9 @@ export function HomepageSidebar({ } }).catch((error) => { }); + fetchUsers(); + } else { + fetchAdminCount(); } }, [adminSheetOpen]); @@ -129,13 +159,13 @@ export function HomepageSidebar({ 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 +188,116 @@ 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 () => { + setUsersLoading(true); + const jwt = getCookie("jwt"); + 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"); + 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; + + 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; + + 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; + + const jwt = getCookie("jwt"); + try { + await API.delete("/delete-user", { + headers: {Authorization: `Bearer ${jwt}`}, + data: {username} + }); + fetchUsers(); + } catch (err: any) { + console.error("Failed to delete user:", err); + } + }; + return (
@@ -201,7 +341,8 @@ export function HomepageSidebar({
- window.open("https://dashix.dev", "_blank")} disabled={disabled}> + window.open("https://dashix.dev", "_blank")} + disabled={disabled}> Tools @@ -243,6 +384,17 @@ export function HomepageSidebar({ onSelect={handleLogout}> Sign out + setDeleteAccountOpen(true)} + disabled={isAdmin && adminCount <= 1} + > + + Delete Account + {isAdmin && adminCount <= 1 && " (Last Admin)"} + + @@ -251,175 +403,357 @@ export function HomepageSidebar({ {/* Admin Settings Sheet (always rendered, only openable if isAdmin) */} {isAdmin && ( - - + + Admin Settings -
- {/* Registration Settings */} -
-

User Registration

- -
- - - {/* OIDC Configuration */} -
-

External Authentication (OIDC)

-

- Configure external identity provider for OIDC/OAuth2 authentication. - Users will see an "External" login option once configured. -

- - {oidcError && ( - - Error - {oidcError} - - )} - -
-
- - handleOIDCConfigChange('client_id', e.target.value)} - placeholder="your-client-id" - required - /> -
- -
- - handleOIDCConfigChange('client_secret', e.target.value)} - placeholder="your-client-secret" - required - /> -
+
+ + + + + Reg + + + + OIDC + + + + Users + + + + Admins + + -
- - handleOIDCConfigChange('authorization_url', e.target.value)} - placeholder="https://your-provider.com/application/o/authorize/" - required - /> -
- -
- - handleOIDCConfigChange('issuer_url', e.target.value)} - placeholder="https://your-provider.com/application/o/termix/" - required - /> -
- -
- - handleOIDCConfigChange('token_url', e.target.value)} - placeholder="http://100.98.3.50:9000/application/o/token/" - required - /> -
- -
- - handleOIDCConfigChange('identifier_path', e.target.value)} - placeholder="sub" - required - /> -

- JSON path to extract user ID from JWT (e.g., "sub", "email", "preferred_username") -

-
- -
- - handleOIDCConfigChange('name_path', e.target.value)} - placeholder="name" - required - /> -

- JSON path to extract display name from JWT (e.g., "name", "preferred_username") -

-
- -
- - handleOIDCConfigChange('scopes', e.target.value)} - placeholder="openid email profile" - required - /> -

- Space-separated list of OAuth2 scopes to request -

-
- -
- - + {/* Registration Settings Tab */} + +
+

User Registration

+
+
- {oidcSuccess && ( - - Success - {oidcSuccess} - - )} - -
+ {/* OIDC Configuration Tab */} + +
+

External Authentication + (OIDC)

+

+ Configure external identity provider for OIDC/OAuth2 authentication. + Users will see an "External" login option once configured. +

+ + {oidcError && ( + + Error + {oidcError} + + )} + +
+
+ + handleOIDCConfigChange('client_id', e.target.value)} + placeholder="your-client-id" + required + /> +
+ +
+ + handleOIDCConfigChange('client_secret', e.target.value)} + placeholder="your-client-secret" + required + /> +
+ +
+ + handleOIDCConfigChange('authorization_url', e.target.value)} + placeholder="https://your-provider.com/application/o/authorize/" + required + /> +
+ +
+ + handleOIDCConfigChange('issuer_url', e.target.value)} + placeholder="https://your-provider.com/application/o/termix/" + required + /> +
+ +
+ + handleOIDCConfigChange('token_url', e.target.value)} + placeholder="http://100.98.3.50:9000/application/o/token/" + required + /> +
+ +
+ + handleOIDCConfigChange('identifier_path', e.target.value)} + placeholder="sub" + required + /> +

+ JSON path to extract user ID from JWT (e.g., "sub", "email", + "preferred_username") +

+
+ +
+ + handleOIDCConfigChange('name_path', e.target.value)} + placeholder="name" + required + /> +

+ JSON path to extract display name from JWT (e.g., "name", + "preferred_username") +

+
+ +
+ + handleOIDCConfigChange('scopes', e.target.value)} + placeholder="openid email profile" + required + /> +

+ Space-separated list of OAuth2 scopes to request +

+
+ +
+ + +
+ + {oidcSuccess && ( + + Success + {oidcSuccess} + + )} +
+
+
+ + {/* Users Management Tab */} + +
+
+

User Management

+ +
+ + {usersLoading ? ( +
+ Loading users... +
+ ) : ( +
+ + + + Username + Type + Actions + + + + {users.map((user) => ( + + + {user.username} + {user.is_admin && ( + + Admin + + )} + + + {user.is_oidc ? "External" : "Local"} + + + + + + ))} + +
+
+ )} +
+
+ + {/* Admins Management Tab */} + +
+

Admin Management

+ + {/* Add New Admin Form */} +
+

Make User Admin

+
+
+ +
+ setNewAdminUsername(e.target.value)} + placeholder="Enter username to make admin" + required + /> + +
+
+ + {makeAdminError && ( + + Error + {makeAdminError} + + )} + + {makeAdminSuccess && ( + + Success + {makeAdminSuccess} + + )} +
+
+ + {/* Current Admins Table */} +
+

Current Admins

+
+ + + + Username + Type + Actions + + + + {users.filter(user => user.is_admin).map((admin) => ( + + + {admin.username} + + Admin + + + + {admin.is_oidc ? "External" : "Local"} + + + + + + ))} + +
+
+
+
+
+
- + + @@ -428,6 +762,84 @@ export function HomepageSidebar({ )} + + {/* Delete Account Confirmation Sheet */} + + + + Delete Account + + This action cannot be undone. This will permanently delete your account and all + associated data. + + +
+ + Warning + + Deleting your account will remove all your data including SSH hosts, + configurations, and settings. + This action is irreversible. + + + + {deleteError && ( + + Error + {deleteError} + + )} + +
+ {isAdmin && adminCount <= 1 && ( + + Cannot Delete Account + + You are the last admin user. You cannot delete your account as this + would leave the system without any administrators. + Please make another user an admin first, or contact system support. + + + )} + +
+ + setDeletePassword(e.target.value)} + placeholder="Enter your password to confirm" + required + disabled={isAdmin && adminCount <= 1} + /> +
+ +
+ + +
+
+
+
+
{children} diff --git a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx index 7fa5e714..e94a8d17 100644 --- a/src/apps/SSH/Manager/SSHManagerHostViewer.tsx +++ b/src/apps/SSH/Manager/SSHManagerHostViewer.tsx @@ -5,8 +5,9 @@ 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"; interface SSHHost { id: number; @@ -36,6 +37,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); + const [importing, setImporting] = useState(false); useEffect(() => { fetchHosts(); @@ -71,6 +73,47 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { } }; + const handleJsonImport = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + setImporting(true); + const text = await file.text(); + const data = JSON.parse(text); + + if (!Array.isArray(data.hosts) && !Array.isArray(data)) { + throw new Error('JSON must contain a "hosts" array or be an array of hosts'); + } + + const hostsArray = Array.isArray(data.hosts) ? data.hosts : data; + + if (hostsArray.length === 0) { + throw new Error('No hosts found in JSON file'); + } + + if (hostsArray.length > 100) { + throw new Error('Maximum 100 hosts allowed per import'); + } + + const result = await bulkImportSSHHosts(hostsArray); + + if (result.success > 0) { + alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`); + await fetchHosts(); + } else { + alert(`Import failed: ${result.errors.join('\n')}`); + } + + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file'; + alert(`Import error: ${errorMessage}`); + } finally { + setImporting(false); + event.target.value = ''; + } + }; + const filteredAndSortedHosts = useMemo(() => { let filtered = hosts; @@ -172,11 +215,370 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { {filteredAndSortedHosts.length} hosts

- +
+ + + + + + +
+

Import SSH Hosts from JSON

+

+ Upload a JSON file to bulk import multiple SSH hosts (max 100). +

+
+
+
+
+ + + + +
+
+ port - SSH port (number, 1-65535) + +
+
+ username - SSH username (string) + +
+
+ authType - "password" or "key" + +
+
+ +

Authentication Fields

+
+
+ password - Required if authType is "password" + +
+
+ key - SSH private key content (string) if authType is "key" + +
+
+ keyPassword - Optional key passphrase + +
+
+ keyType - Key type (auto, ssh-rsa, ssh-ed25519, etc.) + +
+
+ +

Optional Fields

+
+
+ name - Display name (string) + +
+
+ folder - Organization folder (string) + +
+
+ tags - Array of tag strings + +
+
+ pin - Pin to top (boolean) + +
+
+ enableTerminal - Show in Terminal tab (boolean, default: true) + +
+
+ enableTunnel - Show in Tunnel tab (boolean, default: true) + +
+
+ enableConfigEditor - Show in Config Editor tab (boolean, default: true) + +
+
+ defaultPath - Default directory path (string) + +
+
+ +

Tunnel Configuration

+
+
+ tunnelConnections - Array of tunnel objects + +
+
+
+ sourcePort - Local port (number) + +
+
+ endpointPort - Remote port (number) + +
+
+ endpointHost - Target host name (string) + +
+
+ maxRetries - Retry attempts (number, default: 3) + +
+
+ retryInterval - Retry delay in seconds (number, default: 10) + +
+
+ autoStart - Auto-start on launch (boolean, default: false) + +
+
+
+ +

Example JSON Structure

+
{
+  "hosts": [
+    {
+      "name": "Web Server",
+      "ip": "192.168.1.100",
+      "port": 22,
+      "username": "admin",
+      "authType": "password",
+      "password": "your_password",
+      "folder": "Production",
+      "tags": ["web", "production"],
+      "pin": true,
+      "enableTerminal": true,
+      "enableTunnel": false,
+      "enableConfigEditor": true,
+      "defaultPath": "/var/www"
+    }
+  ]
+}
+ +

Important Notes

+
    +
  • Maximum 100 hosts per import
  • +
  • File should contain a "hosts" array or be an array of host objects
  • +
  • All fields are copyable for easy reference
  • +
  • Use the Download Sample button to get a complete example file
  • +
+ + + `); + newWindow.document.close(); + } + }} + > + Format Guide + + + + + +
{ + try { + const response = await sshHostApi.post('/ssh/bulk-import', {hosts}); + return response.data; + } catch (error) { + throw error; + } +} + export async function deleteSSHHost(hostId: number): Promise { try { const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`); diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 841b3799..819af76a 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -94,7 +94,6 @@ router.get('/db/host/internal', async (req: Request, res: Response) => { } try { const data = await db.select().from(sshData); - // Convert tags to array, booleans to bool, tunnelConnections to array const result = data.map((row: any) => ({ ...row, tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [], @@ -116,9 +115,7 @@ router.get('/db/host/internal', async (req: Request, res: Response) => { router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => { let hostData: any; - // Check if this is a multipart form data request (file upload) if (req.headers['content-type']?.includes('multipart/form-data')) { - // Parse the JSON data from the 'data' field if (req.body.data) { try { hostData = JSON.parse(req.body.data); @@ -131,12 +128,10 @@ router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Reque return res.status(400).json({error: 'Missing data field'}); } - // Add the file data if present if (req.file) { hostData.key = req.file.buffer.toString('utf8'); } } else { - // Regular JSON request hostData = req.body; } @@ -697,4 +692,117 @@ router.delete('/config_editor/shortcuts', authenticateJWT, async (req: Request, } }); +// Route: Bulk import SSH hosts from JSON (requires JWT) +// POST /ssh/bulk-import +router.post('/bulk-import', authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const {hosts} = req.body; + + if (!Array.isArray(hosts) || hosts.length === 0) { + logger.warn('Invalid bulk import data - hosts array is required and must not be empty'); + return res.status(400).json({error: 'Hosts array is required and must not be empty'}); + } + + if (hosts.length > 100) { + logger.warn(`Bulk import attempted with too many hosts: ${hosts.length}`); + return res.status(400).json({error: 'Maximum 100 hosts allowed per import'}); + } + + const results = { + success: 0, + failed: 0, + errors: [] as string[] + }; + + for (let i = 0; i < hosts.length; i++) { + const hostData = hosts[i]; + + try { + if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port) || !isNonEmptyString(hostData.username)) { + results.failed++; + results.errors.push(`Host ${i + 1}: Missing or invalid required fields (ip, port, username)`); + continue; + } + + if (hostData.authType !== 'password' && hostData.authType !== 'key') { + results.failed++; + results.errors.push(`Host ${i + 1}: Invalid authType. Must be 'password' or 'key'`); + continue; + } + + if (hostData.authType === 'password' && !isNonEmptyString(hostData.password)) { + results.failed++; + results.errors.push(`Host ${i + 1}: Password required for password authentication`); + continue; + } + + if (hostData.authType === 'key' && !isNonEmptyString(hostData.key)) { + results.failed++; + results.errors.push(`Host ${i + 1}: SSH key required for key authentication`); + continue; + } + + // Validate tunnel connections if enabled + if (hostData.enableTunnel && Array.isArray(hostData.tunnelConnections)) { + for (let j = 0; j < hostData.tunnelConnections.length; j++) { + const conn = hostData.tunnelConnections[j]; + if (!isValidPort(conn.sourcePort) || !isValidPort(conn.endpointPort) || !isNonEmptyString(conn.endpointHost)) { + results.failed++; + results.errors.push(`Host ${i + 1}, Tunnel ${j + 1}: Invalid tunnel connection data`); + break; + } + } + } + + const sshDataObj: any = { + userId: userId, + name: hostData.name || '', + folder: hostData.folder || '', + tags: Array.isArray(hostData.tags) ? hostData.tags.join(',') : (hostData.tags || ''), + ip: hostData.ip, + port: hostData.port, + username: hostData.username, + authType: hostData.authType, + pin: !!hostData.pin ? 1 : 0, + enableTerminal: !!hostData.enableTerminal ? 1 : 0, + enableTunnel: !!hostData.enableTunnel ? 1 : 0, + tunnelConnections: Array.isArray(hostData.tunnelConnections) ? JSON.stringify(hostData.tunnelConnections) : null, + enableConfigEditor: !!hostData.enableConfigEditor ? 1 : 0, + defaultPath: hostData.defaultPath || null, + }; + + if (hostData.authType === 'password') { + sshDataObj.password = hostData.password; + sshDataObj.key = null; + sshDataObj.keyPassword = null; + sshDataObj.keyType = null; + } else if (hostData.authType === 'key') { + sshDataObj.key = hostData.key; + sshDataObj.keyPassword = hostData.keyPassword || null; + sshDataObj.keyType = hostData.keyType || null; + sshDataObj.password = null; + } + + await db.insert(sshData).values(sshDataObj); + results.success++; + + } catch (err) { + results.failed++; + results.errors.push(`Host ${i + 1}: ${err instanceof Error ? err.message : 'Unknown error'}`); + logger.error(`Failed to import host ${i + 1}:`, err); + } + } + + if (results.success > 0) { + logger.success(`Bulk import completed: ${results.success} successful, ${results.failed} failed`); + } else { + logger.warn(`Bulk import failed: ${results.failed} failed`); + } + + res.json({ + message: `Import completed: ${results.success} successful, ${results.failed} failed`, + ...results + }); +}); + export default router; \ No newline at end of file diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 783408aa..ac7c3dd9 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -13,7 +13,7 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str let jwksUrl: string | null = null; const normalizedIssuerUrl = issuerUrl.endsWith('/') ? issuerUrl.slice(0, -1) : issuerUrl; - + try { const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; const discoveryResponse = await fetch(discoveryUrl); @@ -59,12 +59,12 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str logger.warn(`Authentik root JWKS URL also failed: ${error}`); } } - + const jwksResponse = await fetch(jwksUrl); if (!jwksResponse.ok) { throw new Error(`Failed to fetch JWKS from ${jwksUrl}: ${jwksResponse.status}`); } - + const jwks = await jwksResponse.json() as any; const header = JSON.parse(Buffer.from(idToken.split('.')[0], 'base64').toString()); @@ -75,14 +75,14 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str throw new Error(`No matching public key found for key ID: ${keyId}`); } - const { importJWK, jwtVerify } = await import('jose'); + const {importJWK, jwtVerify} = await import('jose'); const key = await importJWK(publicKey); - const { payload } = await jwtVerify(idToken, key, { + const {payload} = await jwtVerify(idToken, key, { issuer: issuerUrl, audience: clientId, }); - + return payload; } catch (error) { logger.error('OIDC token verification failed:', error); @@ -157,14 +157,14 @@ router.post('/create', async (req, res) => { } } catch (e) { } - + const {username, password} = req.body; - + if (!isNonEmptyString(username) || !isNonEmptyString(password)) { logger.warn('Invalid user creation attempt - missing username or password'); - return res.status(400).json({ error: 'Username and password are required' }); + return res.status(400).json({error: 'Username and password are required'}); } - + try { const existing = await db .select() @@ -174,7 +174,7 @@ router.post('/create', async (req, res) => { logger.warn(`Attempt to create duplicate username: ${username}`); return res.status(409).json({error: 'Username already exists'}); } - + let isFirstUser = false; try { const countResult = db.$client.prepare('SELECT COUNT(*) as count FROM users').get(); @@ -182,7 +182,7 @@ router.post('/create', async (req, res) => { } catch (e) { isFirstUser = true; } - + const saltRounds = parseInt(process.env.SALT || '10', 10); const password_hash = await bcrypt.hash(password, saltRounds); const id = nanoid(); @@ -220,7 +220,7 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { if (!user || user.length === 0 || !user[0].is_admin) { return res.status(403).json({error: 'Not authorized'}); } - + const { client_id, client_secret, @@ -231,10 +231,10 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { name_path, scopes } = req.body; - - if (!isNonEmptyString(client_id) || !isNonEmptyString(client_secret) || + + if (!isNonEmptyString(client_id) || !isNonEmptyString(client_secret) || !isNonEmptyString(issuer_url) || !isNonEmptyString(authorization_url) || - !isNonEmptyString(token_url) || !isNonEmptyString(identifier_path) || + !isNonEmptyString(token_url) || !isNonEmptyString(identifier_path) || !isNonEmptyString(name_path)) { return res.status(400).json({error: 'All OIDC configuration fields are required'}); } @@ -249,7 +249,7 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { name_path, scopes: scopes || 'openid email profile' }; - + db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)").run(JSON.stringify(config)); res.json({message: 'OIDC configuration updated'}); @@ -282,7 +282,7 @@ router.get('/oidc/authorize', async (req, res) => { if (!row) { return res.status(404).json({error: 'OIDC not configured'}); } - + const config = JSON.parse((row as any).value); const state = nanoid(); const nonce = nanoid(); @@ -292,13 +292,13 @@ router.get('/oidc/authorize', async (req, res) => { if (origin.includes('localhost')) { origin = 'http://localhost:8081'; } - + const redirectUri = `${origin}/users/oidc/callback`; db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(`oidc_state_${state}`, nonce); db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run(`oidc_redirect_${state}`, redirectUri); - + const authUrl = new URL(config.authorization_url); authUrl.searchParams.set('client_id', config.client_id); authUrl.searchParams.set('redirect_uri', redirectUri); @@ -318,7 +318,7 @@ router.get('/oidc/authorize', async (req, res) => { // GET /users/oidc/callback router.get('/oidc/callback', async (req, res) => { const {code, state} = req.query; - + if (!isNonEmptyString(code) || !isNonEmptyString(state)) { return res.status(400).json({error: 'Code and state are required'}); } @@ -328,7 +328,7 @@ router.get('/oidc/callback', async (req, res) => { return res.status(400).json({error: 'Invalid state parameter - redirect URI not found'}); } const redirectUri = (storedRedirectRow as any).value; - + try { const storedNonce = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`oidc_state_${state}`); if (!storedNonce) { @@ -342,9 +342,9 @@ router.get('/oidc/callback', async (req, res) => { if (!configRow) { return res.status(500).json({error: 'OIDC not configured'}); } - + const config = JSON.parse((configRow as any).value); - + const tokenResponse = await fetch(config.token_url, { method: 'POST', headers: { @@ -358,12 +358,12 @@ router.get('/oidc/callback', async (req, res) => { redirect_uri: redirectUri, }), }); - + if (!tokenResponse.ok) { logger.error('OIDC token exchange failed', await tokenResponse.text()); return res.status(400).json({error: 'Failed to exchange authorization code'}); } - + const tokenData = await tokenResponse.json() as any; let userInfo; @@ -376,13 +376,13 @@ router.get('/oidc/callback', async (req, res) => { const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url; const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ''); const userInfoUrl = `${baseUrl}/userinfo/`; - + const userInfoResponse = await fetch(userInfoUrl, { headers: { 'Authorization': `Bearer ${tokenData.access_token}`, }, }); - + if (userInfoResponse.ok) { userInfo = await userInfoResponse.json(); } else { @@ -394,27 +394,27 @@ router.get('/oidc/callback', async (req, res) => { const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url; const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ''); const userInfoUrl = `${baseUrl}/userinfo/`; - + const userInfoResponse = await fetch(userInfoUrl, { headers: { 'Authorization': `Bearer ${tokenData.access_token}`, }, }); - + if (userInfoResponse.ok) { userInfo = await userInfoResponse.json(); } else { logger.error(`Userinfo endpoint failed with status: ${userInfoResponse.status}`); } } - + if (!userInfo) { return res.status(400).json({error: 'Failed to get user information'}); } const identifier = userInfo[config.identifier_path]; const name = userInfo[config.name_path] || identifier; - + if (!identifier) { logger.error(`Identifier not found at path: ${config.identifier_path}`); logger.error(`Available fields: ${Object.keys(userInfo).join(', ')}`); @@ -425,7 +425,7 @@ router.get('/oidc/callback', async (req, res) => { .select() .from(users) .where(and(eq(users.is_oidc, true), eq(users.oidc_identifier, identifier))); - + let isFirstUser = false; if (!user || user.length === 0) { try { @@ -452,14 +452,14 @@ router.get('/oidc/callback', async (req, res) => { name_path: config.name_path, scopes: config.scopes, }); - + user = await db .select() .from(users) .where(eq(users.id, id)); } else { await db.update(users) - .set({ username: name }) + .set({username: name}) .where(eq(users.id, user[0].id)); user = await db @@ -467,11 +467,11 @@ router.get('/oidc/callback', async (req, res) => { .from(users) .where(eq(users.id, user[0].id)); } - + const userRecord = user[0]; const jwtSecret = process.env.JWT_SECRET || 'secret'; - const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { + const token = jwt.sign({userId: userRecord.id}, jwtSecret, { expiresIn: '50d', }); @@ -480,13 +480,13 @@ router.get('/oidc/callback', async (req, res) => { if (frontendUrl.includes('localhost')) { frontendUrl = 'http://localhost:5173'; } - + const redirectUrl = new URL(frontendUrl); redirectUrl.searchParams.set('success', 'true'); redirectUrl.searchParams.set('token', token); res.redirect(redirectUrl.toString()); - + } catch (err) { logger.error('OIDC callback failed', err); @@ -495,10 +495,10 @@ router.get('/oidc/callback', async (req, res) => { if (frontendUrl.includes('localhost')) { frontendUrl = 'http://localhost:5173'; } - + const redirectUrl = new URL(frontendUrl); redirectUrl.searchParams.set('error', 'OIDC authentication failed'); - + res.redirect(redirectUrl.toString()); } }); @@ -510,7 +510,7 @@ router.post('/login', async (req, res) => { if (!isNonEmptyString(username) || !isNonEmptyString(password)) { logger.warn('Invalid traditional login attempt'); - return res.status(400).json({ error: 'Invalid username or password' }); + return res.status(400).json({error: 'Invalid username or password'}); } try { @@ -521,27 +521,27 @@ router.post('/login', async (req, res) => { if (!user || user.length === 0) { logger.warn(`User not found: ${username}`); - return res.status(404).json({ error: 'User not found' }); + return res.status(404).json({error: 'User not found'}); } const userRecord = user[0]; if (userRecord.is_oidc) { - return res.status(403).json({ error: 'This user uses external authentication' }); + return res.status(403).json({error: 'This user uses external authentication'}); } const isMatch = await bcrypt.compare(password, userRecord.password_hash); if (!isMatch) { logger.warn(`Incorrect password for user: ${username}`); - return res.status(401).json({ error: 'Incorrect password' }); + return res.status(401).json({error: 'Incorrect password'}); } const jwtSecret = process.env.JWT_SECRET || 'secret'; - const token = jwt.sign({ userId: userRecord.id }, jwtSecret, { + const token = jwt.sign({userId: userRecord.id}, jwtSecret, { expiresIn: '50d', }); - return res.json({ + return res.json({ token, is_admin: !!userRecord.is_admin, username: userRecord.username @@ -549,7 +549,7 @@ router.post('/login', async (req, res) => { } catch (err) { logger.error('Failed to log in user', err); - return res.status(500).json({ error: 'Login failed' }); + return res.status(500).json({error: 'Login failed'}); } }); @@ -571,7 +571,7 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => { return res.status(401).json({error: 'User not found'}); } res.json({ - username: user[0].username, + username: user[0].username, is_admin: !!user[0].is_admin, is_oidc: !!user[0].is_oidc }); @@ -639,4 +639,351 @@ router.patch('/registration-allowed', authenticateJWT, async (req, res) => { } }); +// Route: Delete user account +// DELETE /users/delete-account +router.delete('/delete-account', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {password} = req.body; + + if (!isNonEmptyString(password)) { + return res.status(400).json({error: 'Password is required to delete account'}); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + const userRecord = user[0]; + + if (userRecord.is_oidc) { + return res.status(403).json({error: 'Cannot delete external authentication accounts through this endpoint'}); + } + + const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (!isMatch) { + logger.warn(`Incorrect password provided for account deletion: ${userRecord.username}`); + return res.status(401).json({error: 'Incorrect password'}); + } + + if (userRecord.is_admin) { + const adminCount = db.$client.prepare('SELECT COUNT(*) as count FROM users WHERE is_admin = 1').get(); + if ((adminCount as any)?.count <= 1) { + return res.status(403).json({error: 'Cannot delete the last admin user'}); + } + } + + await db.delete(users).where(eq(users.id, userId)); + + logger.success(`User account deleted: ${userRecord.username}`); + res.json({message: 'Account deleted successfully'}); + + } catch (err) { + logger.error('Failed to delete user account', err); + res.status(500).json({error: 'Failed to delete account'}); + } +}); + +// Route: Initiate password reset +// POST /users/initiate-reset +router.post('/initiate-reset', async (req, res) => { + const {username} = req.body; + + if (!isNonEmptyString(username)) { + return res.status(400).json({error: 'Username is required'}); + } + + try { + const user = await db + .select() + .from(users) + .where(eq(users.username, username)); + + if (!user || user.length === 0) { + logger.warn(`Password reset attempted for non-existent user: ${username}`); + return res.status(404).json({error: 'User not found'}); + } + + if (user[0].is_oidc) { + return res.status(403).json({error: 'Password reset not available for external authentication users'}); + } + + const resetCode = Math.floor(100000 + Math.random() * 900000).toString(); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + + db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run( + `reset_code_${username}`, + JSON.stringify({code: resetCode, expiresAt: expiresAt.toISOString()}) + ); + + logger.info(`Password reset code for user ${username}: ${resetCode} (expires at ${expiresAt.toLocaleString()})`); + + res.json({message: 'Password reset code has been generated and logged. Check docker logs for the code.'}); + + } catch (err) { + logger.error('Failed to initiate password reset', err); + res.status(500).json({error: 'Failed to initiate password reset'}); + } +}); + +// Route: Verify reset code +// POST /users/verify-reset-code +router.post('/verify-reset-code', async (req, res) => { + const {username, resetCode} = req.body; + + if (!isNonEmptyString(username) || !isNonEmptyString(resetCode)) { + return res.status(400).json({error: 'Username and reset code are required'}); + } + + try { + const resetDataRow = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`reset_code_${username}`); + if (!resetDataRow) { + return res.status(400).json({error: 'No reset code found for this user'}); + } + + const resetData = JSON.parse((resetDataRow as any).value); + const now = new Date(); + const expiresAt = new Date(resetData.expiresAt); + + if (now > expiresAt) { + db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`reset_code_${username}`); + return res.status(400).json({error: 'Reset code has expired'}); + } + + if (resetData.code !== resetCode) { + return res.status(400).json({error: 'Invalid reset code'}); + } + + const tempToken = nanoid(); + const tempTokenExpiry = new Date(Date.now() + 10 * 60 * 1000); + + db.$client.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)").run( + `temp_reset_token_${username}`, + JSON.stringify({token: tempToken, expiresAt: tempTokenExpiry.toISOString()}) + ); + + res.json({message: 'Reset code verified', tempToken}); + + } catch (err) { + logger.error('Failed to verify reset code', err); + res.status(500).json({error: 'Failed to verify reset code'}); + } +}); + +// Route: Complete password reset +// POST /users/complete-reset +router.post('/complete-reset', async (req, res) => { + const {username, tempToken, newPassword} = req.body; + + if (!isNonEmptyString(username) || !isNonEmptyString(tempToken) || !isNonEmptyString(newPassword)) { + return res.status(400).json({error: 'Username, temporary token, and new password are required'}); + } + + try { + const tempTokenRow = db.$client.prepare("SELECT value FROM settings WHERE key = ?").get(`temp_reset_token_${username}`); + if (!tempTokenRow) { + return res.status(400).json({error: 'No temporary token found'}); + } + + const tempTokenData = JSON.parse((tempTokenRow as any).value); + const now = new Date(); + const expiresAt = new Date(tempTokenData.expiresAt); + + if (now > expiresAt) { + // Clean up expired token + db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${username}`); + return res.status(400).json({error: 'Temporary token has expired'}); + } + + if (tempTokenData.token !== tempToken) { + return res.status(400).json({error: 'Invalid temporary token'}); + } + + const saltRounds = parseInt(process.env.SALT || '10', 10); + const password_hash = await bcrypt.hash(newPassword, saltRounds); + + await db.update(users) + .set({password_hash}) + .where(eq(users.username, username)); + + db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`reset_code_${username}`); + db.$client.prepare("DELETE FROM settings WHERE key = ?").run(`temp_reset_token_${username}`); + + logger.success(`Password successfully reset for user: ${username}`); + res.json({message: 'Password has been successfully reset'}); + + } catch (err) { + logger.error('Failed to complete password reset', err); + res.status(500).json({error: 'Failed to complete password reset'}); + } +}); + +// Route: List all users (admin only) +// GET /users/list +router.get('/list', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0 || !user[0].is_admin) { + return res.status(403).json({error: 'Not authorized'}); + } + + const allUsers = await db.select({ + id: users.id, + username: users.username, + is_admin: users.is_admin, + is_oidc: users.is_oidc + }).from(users); + + res.json({users: allUsers}); + } catch (err) { + logger.error('Failed to list users', err); + res.status(500).json({error: 'Failed to list users'}); + } +}); + +// Route: Make user admin (admin only) +// POST /users/make-admin +router.post('/make-admin', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {username} = req.body; + + if (!isNonEmptyString(username)) { + return res.status(400).json({error: 'Username is required'}); + } + + try { + const adminUser = await db.select().from(users).where(eq(users.id, userId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + return res.status(403).json({error: 'Not authorized'}); + } + + const targetUser = await db.select().from(users).where(eq(users.username, username)); + if (!targetUser || targetUser.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + if (targetUser[0].is_admin) { + return res.status(400).json({error: 'User is already an admin'}); + } + + await db.update(users) + .set({is_admin: true}) + .where(eq(users.username, username)); + + logger.success(`User ${username} made admin by ${adminUser[0].username}`); + res.json({message: `User ${username} is now an admin`}); + + } catch (err) { + logger.error('Failed to make user admin', err); + res.status(500).json({error: 'Failed to make user admin'}); + } +}); + +// Route: Remove admin status (admin only) +// POST /users/remove-admin +router.post('/remove-admin', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {username} = req.body; + + if (!isNonEmptyString(username)) { + return res.status(400).json({error: 'Username is required'}); + } + + try { + const adminUser = await db.select().from(users).where(eq(users.id, userId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + return res.status(403).json({error: 'Not authorized'}); + } + + if (adminUser[0].username === username) { + return res.status(400).json({error: 'Cannot remove your own admin status'}); + } + + const targetUser = await db.select().from(users).where(eq(users.username, username)); + if (!targetUser || targetUser.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + if (!targetUser[0].is_admin) { + return res.status(400).json({error: 'User is not an admin'}); + } + + await db.update(users) + .set({is_admin: false}) + .where(eq(users.username, username)); + + logger.success(`Admin status removed from ${username} by ${adminUser[0].username}`); + res.json({message: `Admin status removed from ${username}`}); + + } catch (err) { + logger.error('Failed to remove admin status', err); + res.status(500).json({error: 'Failed to remove admin status'}); + } +}); + +// Route: Delete user (admin only) +// DELETE /users/delete-user +router.delete('/delete-user', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {username} = req.body; + + if (!isNonEmptyString(username)) { + return res.status(400).json({error: 'Username is required'}); + } + + try { + const adminUser = await db.select().from(users).where(eq(users.id, userId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + return res.status(403).json({error: 'Not authorized'}); + } + + if (adminUser[0].username === username) { + return res.status(400).json({error: 'Cannot delete your own account'}); + } + + const targetUser = await db.select().from(users).where(eq(users.username, username)); + if (!targetUser || targetUser.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + if (targetUser[0].is_admin) { + const adminCount = db.$client.prepare('SELECT COUNT(*) as count FROM users WHERE is_admin = 1').get(); + if ((adminCount as any)?.count <= 1) { + return res.status(403).json({error: 'Cannot delete the last admin user'}); + } + } + + const targetUserId = targetUser[0].id; + + try { + db.$client.prepare('DELETE FROM config_editor_recent WHERE user_id = ?').run(targetUserId); + db.$client.prepare('DELETE FROM config_editor_pinned WHERE user_id = ?').run(targetUserId); + db.$client.prepare('DELETE FROM config_editor_shortcuts WHERE user_id = ?').run(targetUserId); + db.$client.prepare('DELETE FROM ssh_data WHERE user_id = ?').run(targetUserId); + } catch (cleanupError) { + logger.error(`Cleanup failed for user ${username}:`, cleanupError); + } + + await db.delete(users).where(eq(users.id, targetUserId)); + + logger.success(`User ${username} deleted by admin ${adminUser[0].username}`); + res.json({message: `User ${username} deleted successfully`}); + + } catch (err) { + logger.error('Failed to delete user', err); + + if (err && typeof err === 'object' && 'code' in err) { + if (err.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') { + res.status(400).json({error: 'Cannot delete user: User has associated data that cannot be removed'}); + } else { + res.status(500).json({error: `Database error: ${err.code}`}); + } + } else { + res.status(500).json({error: 'Failed to delete account'}); + } + } +}); + export default router; \ No newline at end of file diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 00000000..5513a5cd --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index 4ee26b38..750c93cd 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -52,7 +52,6 @@ function TooltipContent({ {...props} > {children} - )