Confirm and hide password, reset password, delete accounts, better admin page, json import hosts.
This commit is contained in:
@@ -46,9 +46,10 @@ export function HomepageAuth({
|
||||
setDbError,
|
||||
...props
|
||||
}: HomepageAuthProps) {
|
||||
const [tab, setTab] = useState<"login" | "signup" | "external">("login");
|
||||
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login");
|
||||
const [localUsername, setLocalUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -57,6 +58,14 @@ export function HomepageAuth({
|
||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||
|
||||
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
|
||||
const [resetCode, setResetCode] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [tempToken, setTempToken] = useState("");
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [resetSuccess, setResetSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalLoggedIn(loggedIn);
|
||||
}, [loggedIn]);
|
||||
@@ -101,11 +110,28 @@ export function HomepageAuth({
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
if (!localUsername.trim()) {
|
||||
setError("Username is required");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let res, meRes;
|
||||
if (tab === "login") {
|
||||
res = await API.post("/login", {username: localUsername, password});
|
||||
} else {
|
||||
if (password !== signupConfirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters long");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await API.post("/create", {username: localUsername, password});
|
||||
res = await API.post("/login", {username: localUsername, password});
|
||||
}
|
||||
@@ -119,6 +145,9 @@ export function HomepageAuth({
|
||||
setIsAdmin(!!meRes.data.is_admin);
|
||||
setUsername(meRes.data.username || null);
|
||||
setDbError(null);
|
||||
if (tab === "signup") {
|
||||
setSignupConfirmPassword("");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Unknown error");
|
||||
setInternalLoggedIn(false);
|
||||
@@ -136,6 +165,97 @@ export function HomepageAuth({
|
||||
}
|
||||
}
|
||||
|
||||
async function initiatePasswordReset() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
try {
|
||||
await API.post("/initiate-reset", {username: localUsername});
|
||||
setResetStep("verify");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to initiate password reset");
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyResetCode() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
try {
|
||||
const response = await API.post("/verify-reset-code", {
|
||||
username: localUsername,
|
||||
resetCode: resetCode
|
||||
});
|
||||
setTempToken(response.data.tempToken);
|
||||
setResetStep("newPassword");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to verify reset code");
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function completePasswordReset() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError("Password must be at least 6 characters long");
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await API.post("/complete-reset", {
|
||||
username: localUsername,
|
||||
tempToken: tempToken,
|
||||
newPassword: newPassword
|
||||
});
|
||||
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTempToken("");
|
||||
setError(null);
|
||||
|
||||
setResetSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to complete password reset");
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPasswordState() {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTempToken("");
|
||||
setError(null);
|
||||
setResetSuccess(false);
|
||||
setSignupConfirmPassword("");
|
||||
}
|
||||
|
||||
function clearFormFields() {
|
||||
setPassword("");
|
||||
setSignupConfirmPassword("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function resetPassword() {
|
||||
|
||||
}
|
||||
|
||||
async function handleOIDCLogin() {
|
||||
setError(null);
|
||||
setOidcLoading(true);
|
||||
@@ -301,7 +421,11 @@ export function HomepageAuth({
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTab("login")}
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
@@ -315,7 +439,11 @@ export function HomepageAuth({
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTab("signup")}
|
||||
onClick={() => {
|
||||
setTab("signup");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
@@ -330,7 +458,11 @@ export function HomepageAuth({
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTab("external")}
|
||||
onClick={() => {
|
||||
setTab("external");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login" || tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "external"}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
@@ -342,23 +474,185 @@ export function HomepageAuth({
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login" ? "Login to your account" :
|
||||
tab === "signup" ? "Create a new account" :
|
||||
"Login with external provider"}
|
||||
tab === "external" ? "Login with external provider" :
|
||||
"Reset your password"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{tab === "external" ? (
|
||||
{tab === "external" || tab === "reset" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Login using your configured external identity provider</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading ? Spinner : "Login with External Provider"}
|
||||
</Button>
|
||||
{tab === "external" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Login using your configured external identity provider</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading ? Spinner : "Login with External Provider"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{tab === "reset" && (
|
||||
<>
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your username to receive a password reset code. The code
|
||||
will be logged in the docker container logs.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">Username</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={e => setLocalUsername(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
onClick={initiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Send Reset Code"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter the 6-digit code from the docker container logs for
|
||||
user: <strong>{localUsername}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">Reset Code</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={verifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : "Verify Code"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetSuccess && (
|
||||
<>
|
||||
<Alert className="mb-4">
|
||||
<AlertTitle>Success!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your password has been successfully reset! You can now log in
|
||||
with your new password.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
resetPasswordState();
|
||||
}}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your new password for
|
||||
user: <strong>{localUsername}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||
onClick={completePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Reset Password"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
@@ -380,10 +674,33 @@ export function HomepageAuth({
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">Confirm Password</Label>
|
||||
<Input id="signup-confirm-password" type="password" required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={e => setSignupConfirmPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}>
|
||||
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button type="button" variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
onClick={() => {
|
||||
setTab("reset");
|
||||
resetPasswordState();
|
||||
clearFormFields();
|
||||
}}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Computer,
|
||||
Server,
|
||||
File,
|
||||
Hammer, ChevronUp, User2, HardDrive
|
||||
Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -36,6 +36,15 @@ import {Input} from "@/components/ui/input.tsx";
|
||||
import {Label} from "@/components/ui/label.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import axios from "axios";
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -90,6 +99,24 @@ export function HomepageSidebar({
|
||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
|
||||
|
||||
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
|
||||
const [deletePassword, setDeletePassword] = React.useState("");
|
||||
const [deleteLoading, setDeleteLoading] = React.useState(false);
|
||||
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||
const [adminCount, setAdminCount] = React.useState(0);
|
||||
|
||||
const [users, setUsers] = React.useState<Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
}>>([]);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
||||
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
||||
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
|
||||
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (adminSheetOpen) {
|
||||
API.get("/registration-allowed").then(res => {
|
||||
@@ -102,6 +129,9 @@ export function HomepageSidebar({
|
||||
}
|
||||
}).catch((error) => {
|
||||
});
|
||||
fetchUsers();
|
||||
} else {
|
||||
fetchAdminCount();
|
||||
}
|
||||
}, [adminSheetOpen]);
|
||||
|
||||
@@ -129,13 +159,13 @@ export function HomepageSidebar({
|
||||
|
||||
const requiredFields = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
|
||||
const missingFields = requiredFields.filter(field => !oidcConfig[field as keyof typeof oidcConfig]);
|
||||
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
setOidcError(`Missing required fields: ${missingFields.join(', ')}`);
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.post(
|
||||
@@ -158,6 +188,116 @@ export function HomepageSidebar({
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDeleteLoading(true);
|
||||
setDeleteError(null);
|
||||
|
||||
if (!deletePassword.trim()) {
|
||||
setDeleteError("Password is required");
|
||||
setDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.delete("/delete-account", {
|
||||
headers: {Authorization: `Bearer ${jwt}`},
|
||||
data: {password: deletePassword}
|
||||
});
|
||||
|
||||
handleLogout();
|
||||
} catch (err: any) {
|
||||
setDeleteError(err?.response?.data?.error || "Failed to delete account");
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setUsersLoading(true);
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
const response = await API.get("/list", {
|
||||
headers: {Authorization: `Bearer ${jwt}`}
|
||||
});
|
||||
setUsers(response.data.users);
|
||||
|
||||
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
|
||||
setAdminCount(adminUsers.length);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to fetch users:", err);
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAdminCount = async () => {
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
const response = await API.get("/list", {
|
||||
headers: {Authorization: `Bearer ${jwt}`}
|
||||
});
|
||||
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
|
||||
setAdminCount(adminUsers.length);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to fetch admin count:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const makeUserAdmin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newAdminUsername.trim()) return;
|
||||
|
||||
setMakeAdminLoading(true);
|
||||
setMakeAdminError(null);
|
||||
setMakeAdminSuccess(null);
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.post("/make-admin",
|
||||
{username: newAdminUsername.trim()},
|
||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
||||
);
|
||||
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
||||
setNewAdminUsername("");
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
|
||||
} finally {
|
||||
setMakeAdminLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeAdminStatus = async (username: string) => {
|
||||
if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return;
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.post("/remove-admin",
|
||||
{username},
|
||||
{headers: {Authorization: `Bearer ${jwt}`}}
|
||||
);
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error("Failed to remove admin status:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (username: string) => {
|
||||
if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return;
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await API.delete("/delete-user", {
|
||||
headers: {Authorization: `Bearer ${jwt}`},
|
||||
data: {username}
|
||||
});
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error("Failed to delete user:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<SidebarProvider>
|
||||
@@ -201,7 +341,8 @@ export function HomepageSidebar({
|
||||
</SidebarMenuItem>
|
||||
</div>
|
||||
<SidebarMenuItem key={"Tools"}>
|
||||
<SidebarMenuButton onClick={() => window.open("https://dashix.dev", "_blank")} disabled={disabled}>
|
||||
<SidebarMenuButton onClick={() => window.open("https://dashix.dev", "_blank")}
|
||||
disabled={disabled}>
|
||||
<Hammer/>
|
||||
<span>Tools</span>
|
||||
</SidebarMenuButton>
|
||||
@@ -243,6 +384,17 @@ export function HomepageSidebar({
|
||||
onSelect={handleLogout}>
|
||||
<span>Sign out</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onSelect={() => setDeleteAccountOpen(true)}
|
||||
disabled={isAdmin && adminCount <= 1}
|
||||
>
|
||||
<span
|
||||
className={isAdmin && adminCount <= 1 ? "text-muted-foreground" : "text-red-400"}>
|
||||
Delete Account
|
||||
{isAdmin && adminCount <= 1 && " (Last Admin)"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
@@ -251,175 +403,357 @@ export function HomepageSidebar({
|
||||
{/* Admin Settings Sheet (always rendered, only openable if isAdmin) */}
|
||||
{isAdmin && (
|
||||
<Sheet open={adminSheetOpen} onOpenChange={setAdminSheetOpen}>
|
||||
<SheetContent side="left" className="w-[400px] max-h-screen overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetContent side="left" className="w-[700px] max-h-screen overflow-y-auto">
|
||||
<SheetHeader className="px-6 pb-4">
|
||||
<SheetTitle>Admin Settings</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="pt-1 pb-4 px-4 flex flex-col gap-6">
|
||||
{/* Registration Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">User Registration</h3>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
|
||||
disabled={regLoading}/>
|
||||
Allow new account registration
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Separator className="p-0.25 mt-2 mb-2"/>
|
||||
|
||||
{/* OIDC Configuration */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure external identity provider for OIDC/OAuth2 authentication.
|
||||
Users will see an "External" login option once configured.
|
||||
</p>
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{oidcError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_id">Client ID</Label>
|
||||
<Input
|
||||
id="client_id"
|
||||
value={oidcConfig.client_id}
|
||||
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
||||
placeholder="your-client-id"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_secret">Client Secret</Label>
|
||||
<Input
|
||||
id="client_secret"
|
||||
type="password"
|
||||
value={oidcConfig.client_secret}
|
||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
||||
placeholder="your-client-secret"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="px-6">
|
||||
<Tabs defaultValue="registration" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-4 mb-6">
|
||||
<TabsTrigger value="registration" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4"/>
|
||||
Reg
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="oidc" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4"/>
|
||||
OIDC
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4"/>
|
||||
Users
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="admins" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4"/>
|
||||
Admins
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorization_url">Authorization URL</Label>
|
||||
<Input
|
||||
id="authorization_url"
|
||||
value={oidcConfig.authorization_url}
|
||||
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/authorize/"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer_url">Issuer URL</Label>
|
||||
<Input
|
||||
id="issuer_url"
|
||||
value={oidcConfig.issuer_url}
|
||||
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/termix/"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token_url">Token URL</Label>
|
||||
<Input
|
||||
id="token_url"
|
||||
value={oidcConfig.token_url}
|
||||
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
|
||||
placeholder="http://100.98.3.50:9000/application/o/token/"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier_path">User Identifier Path</Label>
|
||||
<Input
|
||||
id="identifier_path"
|
||||
value={oidcConfig.identifier_path}
|
||||
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
|
||||
placeholder="sub"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
JSON path to extract user ID from JWT (e.g., "sub", "email", "preferred_username")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name_path">Display Name Path</Label>
|
||||
<Input
|
||||
id="name_path"
|
||||
value={oidcConfig.name_path}
|
||||
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
|
||||
placeholder="name"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
JSON path to extract display name from JWT (e.g., "name", "preferred_username")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">Scopes</Label>
|
||||
<Input
|
||||
id="scopes"
|
||||
value={oidcConfig.scopes}
|
||||
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
|
||||
placeholder="openid email profile"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Space-separated list of OAuth2 scopes to request
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{oidcLoading ? "Saving..." : "Save Configuration"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setOidcConfig({
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
issuer_url: '',
|
||||
authorization_url: '',
|
||||
token_url: '',
|
||||
identifier_path: 'sub',
|
||||
name_path: 'name',
|
||||
scopes: 'openid email profile'
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{/* Registration Settings Tab */}
|
||||
<TabsContent value="registration" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">User Registration</h3>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
|
||||
disabled={regLoading}/>
|
||||
Allow new account registration
|
||||
</label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{oidcSuccess && (
|
||||
<Alert>
|
||||
<AlertTitle>Success</AlertTitle>
|
||||
<AlertDescription>{oidcSuccess}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
{/* OIDC Configuration Tab */}
|
||||
<TabsContent value="oidc" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">External Authentication
|
||||
(OIDC)</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure external identity provider for OIDC/OAuth2 authentication.
|
||||
Users will see an "External" login option once configured.
|
||||
</p>
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{oidcError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_id">Client ID</Label>
|
||||
<Input
|
||||
id="client_id"
|
||||
value={oidcConfig.client_id}
|
||||
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
||||
placeholder="your-client-id"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_secret">Client Secret</Label>
|
||||
<Input
|
||||
id="client_secret"
|
||||
type="password"
|
||||
value={oidcConfig.client_secret}
|
||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
||||
placeholder="your-client-secret"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorization_url">Authorization URL</Label>
|
||||
<Input
|
||||
id="authorization_url"
|
||||
value={oidcConfig.authorization_url}
|
||||
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/authorize/"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer_url">Issuer URL</Label>
|
||||
<Input
|
||||
id="issuer_url"
|
||||
value={oidcConfig.issuer_url}
|
||||
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/termix/"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token_url">Token URL</Label>
|
||||
<Input
|
||||
id="token_url"
|
||||
value={oidcConfig.token_url}
|
||||
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
|
||||
placeholder="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>
|
||||
<SheetFooter className="px-4 pt-1 pb-4">
|
||||
|
||||
<SheetFooter className="px-6 pt-6 pb-6">
|
||||
<Separator className="p-0.25 mt-2 mb-2"/>
|
||||
<SheetClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
@@ -428,6 +762,84 @@ export function HomepageSidebar({
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* Delete Account Confirmation Sheet */}
|
||||
<Sheet open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
||||
<SheetContent side="left" className="w-[400px]">
|
||||
<SheetHeader className="pb-0">
|
||||
<SheetTitle>Delete Account</SheetTitle>
|
||||
<SheetDescription>
|
||||
This action cannot be undone. This will permanently delete your account and all
|
||||
associated data.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="pb-4 px-4 flex flex-col gap-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertDescription>
|
||||
Deleting your account will remove all your data including SSH hosts,
|
||||
configurations, and settings.
|
||||
This action is irreversible.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{deleteError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{deleteError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleDeleteAccount} className="space-y-4">
|
||||
{isAdmin && adminCount <= 1 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Cannot Delete Account</AlertTitle>
|
||||
<AlertDescription>
|
||||
You are the last admin user. You cannot delete your account as this
|
||||
would leave the system without any administrators.
|
||||
Please make another user an admin first, or contact system support.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-password">Confirm Password</Label>
|
||||
<Input
|
||||
id="delete-password"
|
||||
type="password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
placeholder="Enter your password to confirm"
|
||||
required
|
||||
disabled={isAdmin && adminCount <= 1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={deleteLoading || !deletePassword.trim() || (isAdmin && adminCount <= 1)}
|
||||
>
|
||||
{deleteLoading ? "Deleting..." : "Delete Account"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
{children}
|
||||
|
||||
@@ -5,8 +5,9 @@ import {Badge} from "@/components/ui/badge";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion";
|
||||
import {getSSHHosts, deleteSSHHost} from "@/apps/SSH/ssh-axios";
|
||||
import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search} from "lucide-react";
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/apps/SSH/ssh-axios";
|
||||
import {Edit, Trash2, Server, Folder, Tag, Pin, Terminal, Network, FileEdit, Search, Upload, Info} from "lucide-react";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -36,6 +37,7 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
@@ -71,6 +73,47 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleJsonImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
setImporting(true);
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
|
||||
if (!Array.isArray(data.hosts) && !Array.isArray(data)) {
|
||||
throw new Error('JSON must contain a "hosts" array or be an array of hosts');
|
||||
}
|
||||
|
||||
const hostsArray = Array.isArray(data.hosts) ? data.hosts : data;
|
||||
|
||||
if (hostsArray.length === 0) {
|
||||
throw new Error('No hosts found in JSON file');
|
||||
}
|
||||
|
||||
if (hostsArray.length > 100) {
|
||||
throw new Error('Maximum 100 hosts allowed per import');
|
||||
}
|
||||
|
||||
const result = await bulkImportSSHHosts(hostsArray);
|
||||
|
||||
if (result.success > 0) {
|
||||
alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`);
|
||||
await fetchHosts();
|
||||
} else {
|
||||
alert(`Import failed: ${result.errors.join('\n')}`);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file';
|
||||
alert(`Import error: ${errorMessage}`);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAndSortedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
|
||||
@@ -172,11 +215,370 @@ export function SSHManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
{filteredAndSortedHosts.length} hosts
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
Refresh
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="relative"
|
||||
onClick={() => document.getElementById('json-import-input')?.click()}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing ? 'Importing...' : 'Import JSON'}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom"
|
||||
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">Import SSH Hosts from JSON</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Upload a JSON file to bulk import multiple SSH hosts (max 100).
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const sampleData = {
|
||||
hosts: [
|
||||
{
|
||||
name: "Web Server - Production",
|
||||
ip: "192.168.1.100",
|
||||
port: 22,
|
||||
username: "admin",
|
||||
authType: "password",
|
||||
password: "your_secure_password_here",
|
||||
folder: "Production",
|
||||
tags: ["web", "production", "nginx"],
|
||||
pin: true,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableConfigEditor: true,
|
||||
defaultPath: "/var/www"
|
||||
},
|
||||
{
|
||||
name: "Database Server",
|
||||
ip: "192.168.1.101",
|
||||
port: 22,
|
||||
username: "dbadmin",
|
||||
authType: "key",
|
||||
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
||||
keyPassword: "optional_key_passphrase",
|
||||
keyType: "ssh-ed25519",
|
||||
folder: "Production",
|
||||
tags: ["database", "production", "postgresql"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
enableTunnel: true,
|
||||
enableConfigEditor: false,
|
||||
tunnelConnections: [
|
||||
{
|
||||
sourcePort: 5432,
|
||||
endpointPort: 5432,
|
||||
endpointHost: "Web Server - Production",
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sample-ssh-hosts.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
Download Sample
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const infoContent = `
|
||||
JSON Import Format Guide
|
||||
|
||||
REQUIRED FIELDS:
|
||||
• ip: Host IP address (string)
|
||||
• port: SSH port (number, 1-65535)
|
||||
• username: SSH username (string)
|
||||
• authType: "password" or "key"
|
||||
|
||||
AUTHENTICATION FIELDS:
|
||||
• password: Required if authType is "password"
|
||||
• key: SSH private key content (string) if authType is "key"
|
||||
• keyPassword: Optional key passphrase
|
||||
• keyType: Key type (auto, ssh-rsa, ssh-ed25519, etc.)
|
||||
|
||||
OPTIONAL FIELDS:
|
||||
• name: Display name (string)
|
||||
• folder: Organization folder (string)
|
||||
• tags: Array of tag strings
|
||||
• pin: Pin to top (boolean)
|
||||
• enableTerminal: Show in Terminal tab (boolean, default: true)
|
||||
• enableTunnel: Show in Tunnel tab (boolean, default: true)
|
||||
• enableConfigEditor: Show in Config Editor tab (boolean, default: true)
|
||||
• defaultPath: Default directory path (string)
|
||||
|
||||
TUNNEL CONFIGURATION:
|
||||
• tunnelConnections: Array of tunnel objects
|
||||
- sourcePort: Local port (number)
|
||||
- endpointPort: Remote port (number)
|
||||
- endpointHost: Target host name (string)
|
||||
- maxRetries: Retry attempts (number, default: 3)
|
||||
- retryInterval: Retry delay in seconds (number, default: 10)
|
||||
- autoStart: Auto-start on launch (boolean, default: false)
|
||||
|
||||
EXAMPLE STRUCTURE:
|
||||
{
|
||||
"hosts": [
|
||||
{
|
||||
"name": "Web Server",
|
||||
"ip": "192.168.1.100",
|
||||
"port": 22,
|
||||
"username": "admin",
|
||||
"authType": "password",
|
||||
"password": "your_password",
|
||||
"folder": "Production",
|
||||
"tags": ["web", "production"],
|
||||
"pin": true,
|
||||
"enableTerminal": true,
|
||||
"enableTunnel": false,
|
||||
"enableConfigEditor": true,
|
||||
"defaultPath": "/var/www"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
• Maximum 100 hosts per import
|
||||
• File should contain a "hosts" array or be an array of host objects
|
||||
• All fields are copyable for easy reference
|
||||
`;
|
||||
|
||||
const newWindow = window.open('', '_blank', 'width=600,height=800,scrollbars=yes,resizable=yes');
|
||||
if (newWindow) {
|
||||
newWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>SSH JSON Import Guide</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 20px;
|
||||
background: #1a1a1a;
|
||||
color: #ffffff;
|
||||
line-height: 1.6;
|
||||
}
|
||||
pre {
|
||||
background: #2a2a2a;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #404040;
|
||||
}
|
||||
code {
|
||||
background: #404040;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
h1 { color: #60a5fa; border-bottom: 2px solid #60a5fa; padding-bottom: 10px; }
|
||||
h2 { color: #34d399; margin-top: 25px; }
|
||||
.field-group { margin: 15px 0; }
|
||||
.field-item { margin: 8px 0; }
|
||||
.copy-btn {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.copy-btn:hover { background: #2563eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>SSH JSON Import Format Guide</h1>
|
||||
<p>Use this guide to create JSON files for bulk importing SSH hosts. All examples are copyable.</p>
|
||||
|
||||
<h2>Required Fields</h2>
|
||||
<div class="field-group">
|
||||
<div class="field-item">
|
||||
<code>ip</code> - Host IP address (string)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('ip')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>port</code> - SSH port (number, 1-65535)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('port')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>username</code> - SSH username (string)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('username')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>authType</code> - "password" or "key"
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('authType')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Authentication Fields</h2>
|
||||
<div class="field-group">
|
||||
<div class="field-item">
|
||||
<code>password</code> - Required if authType is "password"
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('password')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>key</code> - SSH private key content (string) if authType is "key"
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('key')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>keyPassword</code> - Optional key passphrase
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('keyPassword')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>keyType</code> - Key type (auto, ssh-rsa, ssh-ed25519, etc.)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('keyType')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Optional Fields</h2>
|
||||
<div class="field-group">
|
||||
<div class="field-item">
|
||||
<code>name</code> - Display name (string)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('name')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>folder</code> - Organization folder (string)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('folder')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>tags</code> - Array of tag strings
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('tags')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>pin</code> - Pin to top (boolean)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('pin')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>enableTerminal</code> - Show in Terminal tab (boolean, default: true)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('enableTerminal')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>enableTunnel</code> - Show in Tunnel tab (boolean, default: true)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('enableTunnel')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>enableConfigEditor</code> - Show in Config Editor tab (boolean, default: true)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('enableConfigEditor')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>defaultPath</code> - Default directory path (string)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('defaultPath')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Tunnel Configuration</h2>
|
||||
<div class="field-group">
|
||||
<div class="field-item">
|
||||
<code>tunnelConnections</code> - Array of tunnel objects
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('tunnelConnections')">Copy</button>
|
||||
</div>
|
||||
<div style="margin-left: 20px;">
|
||||
<div class="field-item">
|
||||
<code>sourcePort</code> - Local port (number)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('sourcePort')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>endpointPort</code> - Remote port (number)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('endpointPort')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>endpointHost</code> - Target host name (string)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('endpointHost')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>maxRetries</code> - Retry attempts (number, default: 3)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('maxRetries')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>retryInterval</code> - Retry delay in seconds (number, default: 10)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('retryInterval')">Copy</button>
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<code>autoStart</code> - Auto-start on launch (boolean, default: false)
|
||||
<button class="copy-btn" onclick="navigator.clipboard.writeText('autoStart')">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Example JSON Structure</h2>
|
||||
<pre><code>{
|
||||
"hosts": [
|
||||
{
|
||||
"name": "Web Server",
|
||||
"ip": "192.168.1.100",
|
||||
"port": 22,
|
||||
"username": "admin",
|
||||
"authType": "password",
|
||||
"password": "your_password",
|
||||
"folder": "Production",
|
||||
"tags": ["web", "production"],
|
||||
"pin": true,
|
||||
"enableTerminal": true,
|
||||
"enableTunnel": false,
|
||||
"enableConfigEditor": true,
|
||||
"defaultPath": "/var/www"
|
||||
}
|
||||
]
|
||||
}</code></pre>
|
||||
|
||||
<h2>Important Notes</h2>
|
||||
<ul>
|
||||
<li>Maximum 100 hosts per import</li>
|
||||
<li>File should contain a "hosts" array or be an array of host objects</li>
|
||||
<li>All fields are copyable for easy reference</li>
|
||||
<li>Use the Download Sample button to get a complete example file</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
newWindow.document.close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Format Guide
|
||||
</Button>
|
||||
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="json-import-input"
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleJsonImport}
|
||||
style={{display: 'none'}}
|
||||
/>
|
||||
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input
|
||||
|
||||
@@ -262,6 +262,20 @@ export async function updateSSHHost(hostId: number, hostData: SSHHostData): Prom
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkImportSSHHosts(hosts: SSHHostData[]): Promise<{
|
||||
message: string;
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.post('/ssh/bulk-import', {hosts});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSSHHost(hostId: number): Promise<any> {
|
||||
try {
|
||||
const response = await sshHostApi.delete(`/ssh/db/host/${hostId}`);
|
||||
|
||||
Reference in New Issue
Block a user