Confirm and hide password, reset password, delete accounts, better admin page, json import hosts.
This commit is contained in:
@@ -40,6 +40,7 @@ Termix is an open-source, forever-free, self-hosted all-in-one server management
|
|||||||
- **Theming** - Modify themeing for all tools
|
- **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 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)
|
- **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
|
# 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:
|
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:
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ export function HomepageAuth({
|
|||||||
setDbError,
|
setDbError,
|
||||||
...props
|
...props
|
||||||
}: HomepageAuthProps) {
|
}: HomepageAuthProps) {
|
||||||
const [tab, setTab] = useState<"login" | "signup" | "external">("login");
|
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login");
|
||||||
const [localUsername, setLocalUsername] = useState("");
|
const [localUsername, setLocalUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [oidcLoading, setOidcLoading] = useState(false);
|
const [oidcLoading, setOidcLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -57,6 +58,14 @@ export function HomepageAuth({
|
|||||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
setInternalLoggedIn(loggedIn);
|
setInternalLoggedIn(loggedIn);
|
||||||
}, [loggedIn]);
|
}, [loggedIn]);
|
||||||
@@ -101,11 +110,28 @@ export function HomepageAuth({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
|
if (!localUsername.trim()) {
|
||||||
|
setError("Username is required");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let res, meRes;
|
let res, meRes;
|
||||||
if (tab === "login") {
|
if (tab === "login") {
|
||||||
res = await API.post("/login", {username: localUsername, password});
|
res = await API.post("/login", {username: localUsername, password});
|
||||||
} else {
|
} 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});
|
await API.post("/create", {username: localUsername, password});
|
||||||
res = await API.post("/login", {username: localUsername, password});
|
res = await API.post("/login", {username: localUsername, password});
|
||||||
}
|
}
|
||||||
@@ -119,6 +145,9 @@ export function HomepageAuth({
|
|||||||
setIsAdmin(!!meRes.data.is_admin);
|
setIsAdmin(!!meRes.data.is_admin);
|
||||||
setUsername(meRes.data.username || null);
|
setUsername(meRes.data.username || null);
|
||||||
setDbError(null);
|
setDbError(null);
|
||||||
|
if (tab === "signup") {
|
||||||
|
setSignupConfirmPassword("");
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.response?.data?.error || "Unknown error");
|
setError(err?.response?.data?.error || "Unknown error");
|
||||||
setInternalLoggedIn(false);
|
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() {
|
async function handleOIDCLogin() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setOidcLoading(true);
|
setOidcLoading(true);
|
||||||
@@ -301,7 +421,11 @@ export function HomepageAuth({
|
|||||||
? "bg-primary text-primary-foreground shadow"
|
? "bg-primary text-primary-foreground shadow"
|
||||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
: "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"}
|
aria-selected={tab === "login"}
|
||||||
disabled={loading || firstUser}
|
disabled={loading || firstUser}
|
||||||
>
|
>
|
||||||
@@ -315,7 +439,11 @@ export function HomepageAuth({
|
|||||||
? "bg-primary text-primary-foreground shadow"
|
? "bg-primary text-primary-foreground shadow"
|
||||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
: "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"}
|
aria-selected={tab === "signup"}
|
||||||
disabled={loading || !registrationAllowed}
|
disabled={loading || !registrationAllowed}
|
||||||
>
|
>
|
||||||
@@ -330,7 +458,11 @@ export function HomepageAuth({
|
|||||||
? "bg-primary text-primary-foreground shadow"
|
? "bg-primary text-primary-foreground shadow"
|
||||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
: "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"}
|
aria-selected={tab === "external"}
|
||||||
disabled={oidcLoading}
|
disabled={oidcLoading}
|
||||||
>
|
>
|
||||||
@@ -342,23 +474,185 @@ export function HomepageAuth({
|
|||||||
<h2 className="text-xl font-bold mb-1">
|
<h2 className="text-xl font-bold mb-1">
|
||||||
{tab === "login" ? "Login to your account" :
|
{tab === "login" ? "Login to your account" :
|
||||||
tab === "signup" ? "Create a new account" :
|
tab === "signup" ? "Create a new account" :
|
||||||
"Login with external provider"}
|
tab === "external" ? "Login with external provider" :
|
||||||
|
"Reset your password"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "external" ? (
|
{tab === "external" || tab === "reset" ? (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="text-center text-muted-foreground mb-4">
|
{tab === "external" && (
|
||||||
<p>Login using your configured external identity provider</p>
|
<>
|
||||||
</div>
|
<div className="text-center text-muted-foreground mb-4">
|
||||||
<Button
|
<p>Login using your configured external identity provider</p>
|
||||||
type="button"
|
</div>
|
||||||
className="w-full h-11 mt-2 text-base font-semibold"
|
<Button
|
||||||
disabled={oidcLoading}
|
type="button"
|
||||||
onClick={handleOIDCLogin}
|
className="w-full h-11 mt-2 text-base font-semibold"
|
||||||
>
|
disabled={oidcLoading}
|
||||||
{oidcLoading ? Spinner : "Login with External Provider"}
|
onClick={handleOIDCLogin}
|
||||||
</Button>
|
>
|
||||||
|
{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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||||
@@ -380,10 +674,33 @@ export function HomepageAuth({
|
|||||||
value={password} onChange={e => setPassword(e.target.value)}
|
value={password} onChange={e => setPassword(e.target.value)}
|
||||||
disabled={loading || internalLoggedIn}/>
|
disabled={loading || internalLoggedIn}/>
|
||||||
</div>
|
</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"
|
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
||||||
disabled={loading || internalLoggedIn}>
|
disabled={loading || internalLoggedIn}>
|
||||||
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
||||||
</Button>
|
</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>
|
</form>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Computer,
|
Computer,
|
||||||
Server,
|
Server,
|
||||||
File,
|
File,
|
||||||
Hammer, ChevronUp, User2, HardDrive
|
Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +36,15 @@ import {Input} from "@/components/ui/input.tsx";
|
|||||||
import {Label} from "@/components/ui/label.tsx";
|
import {Label} from "@/components/ui/label.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.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";
|
import axios from "axios";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -90,6 +99,24 @@ export function HomepageSidebar({
|
|||||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||||
const [oidcSuccess, setOidcSuccess] = 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(() => {
|
React.useEffect(() => {
|
||||||
if (adminSheetOpen) {
|
if (adminSheetOpen) {
|
||||||
API.get("/registration-allowed").then(res => {
|
API.get("/registration-allowed").then(res => {
|
||||||
@@ -102,6 +129,9 @@ export function HomepageSidebar({
|
|||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
});
|
});
|
||||||
|
fetchUsers();
|
||||||
|
} else {
|
||||||
|
fetchAdminCount();
|
||||||
}
|
}
|
||||||
}, [adminSheetOpen]);
|
}, [adminSheetOpen]);
|
||||||
|
|
||||||
@@ -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 (
|
return (
|
||||||
<div className="min-h-svh">
|
<div className="min-h-svh">
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
@@ -201,7 +341,8 @@ export function HomepageSidebar({
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</div>
|
</div>
|
||||||
<SidebarMenuItem key={"Tools"}>
|
<SidebarMenuItem key={"Tools"}>
|
||||||
<SidebarMenuButton onClick={() => window.open("https://dashix.dev", "_blank")} disabled={disabled}>
|
<SidebarMenuButton onClick={() => window.open("https://dashix.dev", "_blank")}
|
||||||
|
disabled={disabled}>
|
||||||
<Hammer/>
|
<Hammer/>
|
||||||
<span>Tools</span>
|
<span>Tools</span>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@@ -243,6 +384,17 @@ export function HomepageSidebar({
|
|||||||
onSelect={handleLogout}>
|
onSelect={handleLogout}>
|
||||||
<span>Sign out</span>
|
<span>Sign out</span>
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
@@ -251,175 +403,357 @@ export function HomepageSidebar({
|
|||||||
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */}
|
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}>
|
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}>
|
||||||
<SheetContent side="left" className="w-[400px] max-h-screen overflow-y-auto">
|
<SheetContent side="left" className="w-[700px] max-h-screen overflow-y-auto">
|
||||||
<SheetHeader>
|
<SheetHeader className="px-6 pb-4">
|
||||||
<SheetTitle>Admin Settings</SheetTitle>
|
<SheetTitle>Admin Settings</SheetTitle>
|
||||||
</SheetHeader>
|
</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"/>
|
<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>
|
||||||
|
|
||||||
{/* OIDC Configuration */}
|
{/* Registration Settings Tab */}
|
||||||
<div className="space-y-4">
|
<TabsContent value="registration" className="space-y-6">
|
||||||
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<h3 className="text-lg font-semibold">User Registration</h3>
|
||||||
Configure external identity provider for OIDC/OAuth2 authentication.
|
<label className="flex items-center gap-2">
|
||||||
Users will see an "External" login option once configured.
|
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
|
||||||
</p>
|
disabled={regLoading}/>
|
||||||
|
Allow new account registration
|
||||||
{oidcError && (
|
</label>
|
||||||
<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>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* OIDC Configuration Tab */}
|
||||||
<Label htmlFor="client_secret">Client Secret</Label>
|
<TabsContent value="oidc" className="space-y-6">
|
||||||
<Input
|
<div className="space-y-4">
|
||||||
id="client_secret"
|
<h3 className="text-lg font-semibold">External Authentication
|
||||||
type="password"
|
(OIDC)</h3>
|
||||||
value={oidcConfig.client_secret}
|
<p className="text-sm text-muted-foreground">
|
||||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
Configure external identity provider for OIDC/OAuth2 authentication.
|
||||||
placeholder="your-client-secret"
|
Users will see an "External" login option once configured.
|
||||||
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="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>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
{oidcError && (
|
||||||
<Label htmlFor="name_path">Display Name Path</Label>
|
<Alert variant="destructive">
|
||||||
<Input
|
<AlertTitle>Error</AlertTitle>
|
||||||
id="name_path"
|
<AlertDescription>{oidcError}</AlertDescription>
|
||||||
value={oidcConfig.name_path}
|
</Alert>
|
||||||
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">
|
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||||
<Label htmlFor="scopes">Scopes</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label htmlFor="client_id">Client ID</Label>
|
||||||
id="scopes"
|
<Input
|
||||||
value={oidcConfig.scopes}
|
id="client_id"
|
||||||
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
|
value={oidcConfig.client_id}
|
||||||
placeholder="openid email profile"
|
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
||||||
required
|
placeholder="your-client-id"
|
||||||
/>
|
required
|
||||||
<p className="text-xs text-muted-foreground">
|
/>
|
||||||
Space-separated list of OAuth2 scopes to request
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="space-y-2">
|
||||||
<Button
|
<Label htmlFor="client_secret">Client Secret</Label>
|
||||||
type="submit"
|
<Input
|
||||||
className="flex-1"
|
id="client_secret"
|
||||||
disabled={oidcLoading}
|
type="password"
|
||||||
>
|
value={oidcConfig.client_secret}
|
||||||
{oidcLoading ? "Saving..." : "Save Configuration"}
|
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
||||||
</Button>
|
placeholder="your-client-secret"
|
||||||
<Button
|
required
|
||||||
type="button"
|
/>
|
||||||
variant="outline"
|
</div>
|
||||||
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 && (
|
<div className="space-y-2">
|
||||||
<Alert>
|
<Label htmlFor="authorization_url">Authorization URL</Label>
|
||||||
<AlertTitle>Success</AlertTitle>
|
<Input
|
||||||
<AlertDescription>{oidcSuccess}</AlertDescription>
|
id="authorization_url"
|
||||||
</Alert>
|
value={oidcConfig.authorization_url}
|
||||||
)}
|
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
||||||
</form>
|
placeholder="https://your-provider.com/application/o/authorize/"
|
||||||
</div>
|
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 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-blue-100 text-blue-800">
|
||||||
|
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-blue-100 text-blue-800">
|
||||||
|
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>
|
</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"/>
|
<Separator className="p-0.25 mt-2 mb-2"/>
|
||||||
<SheetClose asChild>
|
<SheetClose asChild>
|
||||||
<Button variant="outline">Close</Button>
|
<Button variant="outline">Close</Button>
|
||||||
@@ -428,6 +762,84 @@ export function HomepageSidebar({
|
|||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</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>
|
</Sidebar>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import {Badge} from "@/components/ui/badge";
|
|||||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
import {ScrollArea} from "@/components/ui/scroll-area";
|
||||||
import {Input} from "@/components/ui/input";
|
import {Input} from "@/components/ui/input";
|
||||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
|
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
|
||||||
import {getSSHHosts, deleteSSHHost} from "@/apps/SSH/ssh-axios";
|
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||||
import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search} from "lucide-react";
|
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 {
|
interface SSHHost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -36,6 +37,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
@@ -71,6 +73,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(() => {
|
const filteredAndSortedHosts = useMemo(() => {
|
||||||
let filtered = hosts;
|
let filtered = hosts;
|
||||||
|
|
||||||
@@ -172,11 +215,370 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
{filteredAndSortedHosts.length} hosts
|
{filteredAndSortedHosts.length} hosts
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
<div className="flex items-center gap-2">
|
||||||
Refresh
|
<TooltipProvider>
|
||||||
</Button>
|
<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>
|
||||||
|
|
||||||
|
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id="json-import-input"
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={handleJsonImport}
|
||||||
|
style={{display: 'none'}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="relative mb-3">
|
<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"/>
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -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> {
|
export async function deleteSSHHost(hostId: number): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`);
|
const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`);
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ router.get('/db/host/internal', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await db.select().from(sshData);
|
const data = await db.select().from(sshData);
|
||||||
// Convert tags to array, booleans to bool, tunnelConnections to array
|
|
||||||
const result = data.map((row: any) => ({
|
const result = data.map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
tags: typeof row.tags === 'string' ? (row.tags ? row.tags.split(',').filter(Boolean) : []) : [],
|
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) => {
|
router.post('/db/host', authenticateJWT, upload.single('key'), async (req: Request, res: Response) => {
|
||||||
let hostData: any;
|
let hostData: any;
|
||||||
|
|
||||||
// Check if this is a multipart form data request (file upload)
|
|
||||||
if (req.headers['content-type']?.includes('multipart/form-data')) {
|
if (req.headers['content-type']?.includes('multipart/form-data')) {
|
||||||
// Parse the JSON data from the 'data' field
|
|
||||||
if (req.body.data) {
|
if (req.body.data) {
|
||||||
try {
|
try {
|
||||||
hostData = JSON.parse(req.body.data);
|
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'});
|
return res.status(400).json({error: 'Missing data field'});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the file data if present
|
|
||||||
if (req.file) {
|
if (req.file) {
|
||||||
hostData.key = req.file.buffer.toString('utf8');
|
hostData.key = req.file.buffer.toString('utf8');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Regular JSON request
|
|
||||||
hostData = req.body;
|
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;
|
export default router;
|
||||||
@@ -75,10 +75,10 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str
|
|||||||
throw new Error(`No matching public key found for key ID: ${keyId}`);
|
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 key = await importJWK(publicKey);
|
||||||
|
|
||||||
const { payload } = await jwtVerify(idToken, key, {
|
const {payload} = await jwtVerify(idToken, key, {
|
||||||
issuer: issuerUrl,
|
issuer: issuerUrl,
|
||||||
audience: clientId,
|
audience: clientId,
|
||||||
});
|
});
|
||||||
@@ -162,7 +162,7 @@ router.post('/create', async (req, res) => {
|
|||||||
|
|
||||||
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
||||||
logger.warn('Invalid user creation attempt - missing username or 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 {
|
try {
|
||||||
@@ -232,7 +232,7 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => {
|
|||||||
scopes
|
scopes
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!isNonEmptyString(client_id) || !isNonEmptyString(client_secret) ||
|
if (!isNonEmptyString(client_id) || !isNonEmptyString(client_secret) ||
|
||||||
!isNonEmptyString(issuer_url) || !isNonEmptyString(authorization_url) ||
|
!isNonEmptyString(issuer_url) || !isNonEmptyString(authorization_url) ||
|
||||||
!isNonEmptyString(token_url) || !isNonEmptyString(identifier_path) ||
|
!isNonEmptyString(token_url) || !isNonEmptyString(identifier_path) ||
|
||||||
!isNonEmptyString(name_path)) {
|
!isNonEmptyString(name_path)) {
|
||||||
@@ -459,7 +459,7 @@ router.get('/oidc/callback', async (req, res) => {
|
|||||||
.where(eq(users.id, id));
|
.where(eq(users.id, id));
|
||||||
} else {
|
} else {
|
||||||
await db.update(users)
|
await db.update(users)
|
||||||
.set({ username: name })
|
.set({username: name})
|
||||||
.where(eq(users.id, user[0].id));
|
.where(eq(users.id, user[0].id));
|
||||||
|
|
||||||
user = await db
|
user = await db
|
||||||
@@ -471,7 +471,7 @@ router.get('/oidc/callback', async (req, res) => {
|
|||||||
const userRecord = user[0];
|
const userRecord = user[0];
|
||||||
|
|
||||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
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',
|
expiresIn: '50d',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -510,7 +510,7 @@ router.post('/login', async (req, res) => {
|
|||||||
|
|
||||||
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
|
||||||
logger.warn('Invalid traditional login attempt');
|
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 {
|
try {
|
||||||
@@ -521,23 +521,23 @@ router.post('/login', async (req, res) => {
|
|||||||
|
|
||||||
if (!user || user.length === 0) {
|
if (!user || user.length === 0) {
|
||||||
logger.warn(`User not found: ${username}`);
|
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];
|
const userRecord = user[0];
|
||||||
|
|
||||||
if (userRecord.is_oidc) {
|
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);
|
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
|
||||||
if (!isMatch) {
|
if (!isMatch) {
|
||||||
logger.warn(`Incorrect password for user: ${username}`);
|
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 jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||||
const token = jwt.sign({ userId: userRecord.id }, jwtSecret, {
|
const token = jwt.sign({userId: userRecord.id}, jwtSecret, {
|
||||||
expiresIn: '50d',
|
expiresIn: '50d',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -549,7 +549,7 @@ router.post('/login', async (req, res) => {
|
|||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to log in user', 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'});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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;
|
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}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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.Content>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user