mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
feat: continued improvements
This commit is contained in:
+4
-5
@@ -5,7 +5,6 @@
|
||||
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12.0",
|
||||
"npm": ">=11"
|
||||
@@ -19,12 +18,12 @@
|
||||
"lint:fix": "eslint --fix .",
|
||||
"type-check": "tsc --noEmit",
|
||||
"dev": "vite",
|
||||
"build": "vite build && tsc -p tsconfig.node.json",
|
||||
"build:backend": "tsc -p tsconfig.node.json",
|
||||
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
|
||||
"build": "vite build && tsc -p tsconfig.node.json && powershell -c \"Copy-Item src/backend/package.json dist/backend/package.json\"",
|
||||
"build:backend": "tsc -p tsconfig.node.json && powershell -c \"Copy-Item src/backend/package.json dist/backend/package.json\"",
|
||||
"dev:backend": "tsc -p tsconfig.node.json && powershell -c \"Copy-Item src/backend/package.json dist/backend/package.json\" && node ./dist/backend/backend/starter.js",
|
||||
"dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev",
|
||||
"dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001-30006:30001-30006 -v \"%cd%\\db\\data:/app/data\" termix:dev",
|
||||
"generate:openapi": "tsc -p tsconfig.node.json && node ./dist/backend/backend/swagger.js",
|
||||
"generate:openapi": "tsc -p tsconfig.node.json && powershell -c \"Copy-Item src/backend/package.json dist/backend/package.json\" && node ./dist/backend/backend/swagger.js",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "concurrently \"npm run dev\" \"powershell -c \\\"Start-Sleep -Seconds 5\\\" && electron .\"",
|
||||
"electron:patch-builder": "node scripts/patch-app-builder-lib.cjs",
|
||||
|
||||
@@ -291,10 +291,23 @@ function transformHostResponse(
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
enableSsh: host.enableSsh !== undefined ? !!host.enableSsh : true,
|
||||
enableRdp: !!host.enableRdp,
|
||||
enableVnc: !!host.enableVnc,
|
||||
enableTelnet: !!host.enableTelnet,
|
||||
// Old hosts only had connection_type set; the per-protocol enable flags didn't exist yet.
|
||||
// The schema defaults (enableSsh=true, others=false) wrongly mark every old host as SSH.
|
||||
// Detect this migration case: if no non-SSH protocol is explicitly enabled AND
|
||||
// connectionType is set to a non-SSH value, fall back to inferring from connectionType.
|
||||
...(() => {
|
||||
const ct = host.connectionType;
|
||||
const rdp = !!host.enableRdp;
|
||||
const vnc = !!host.enableVnc;
|
||||
const tel = !!host.enableTelnet;
|
||||
const isMigratedNonSsh = !rdp && !vnc && !tel && ct && ct !== "ssh";
|
||||
return {
|
||||
enableSsh: isMigratedNonSsh ? false : !!host.enableSsh,
|
||||
enableRdp: isMigratedNonSsh ? ct === "rdp" : rdp,
|
||||
enableVnc: isMigratedNonSsh ? ct === "vnc" : vnc,
|
||||
enableTelnet: isMigratedNonSsh ? ct === "telnet" : tel,
|
||||
};
|
||||
})(),
|
||||
sshPort: host.sshPort ?? host.port ?? 22,
|
||||
rdpPort: host.rdpPort ?? 3389,
|
||||
vncPort: host.vncPort ?? 5900,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
+71
-6
@@ -149,7 +149,7 @@ export function AppShell({
|
||||
const [railView, setRailView] = useState<RailView>("hosts");
|
||||
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
|
||||
const [hostManagerExpanded, setHostManagerExpanded] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(256);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(266);
|
||||
const [sidebarDragging, setSidebarDragging] = useState(false);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
@@ -205,6 +205,24 @@ export function AppShell({
|
||||
return () => dbHealthMonitor.off("session-expired", handleSessionExpired);
|
||||
}, [onLogout]);
|
||||
|
||||
useEffect(() => {
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
if (!activeTab?.terminalRef) return;
|
||||
let innerRafId: number;
|
||||
const outerRafId = requestAnimationFrame(() => {
|
||||
innerRafId = requestAnimationFrame(() => {
|
||||
const ref = activeTab.terminalRef?.current;
|
||||
ref?.fit?.();
|
||||
ref?.notifyResize?.();
|
||||
ref?.refresh?.();
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(outerRafId);
|
||||
cancelAnimationFrame(innerRafId);
|
||||
};
|
||||
}, [activeTabId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDegraded = () => {
|
||||
toast.loading(t("common.connectionDegraded"), {
|
||||
@@ -513,7 +531,7 @@ export function AppShell({
|
||||
size="icon"
|
||||
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
||||
title="Reset width"
|
||||
onClick={() => setSidebarWidth(256)}
|
||||
onClick={() => setSidebarWidth(266)}
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</Button>
|
||||
@@ -616,7 +634,7 @@ export function AppShell({
|
||||
onCloseTab={closeTab}
|
||||
onReorderTabs={setTabs}
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
{isSplit && !isMobile ? (
|
||||
<SplitView
|
||||
tabs={tabs}
|
||||
@@ -625,9 +643,56 @@ export function AppShell({
|
||||
onOpenSingletonTab={openSingletonTab}
|
||||
onOpenTab={openTab}
|
||||
/>
|
||||
) : activeTab ? (
|
||||
renderTabContent(activeTab, openSingletonTab, openTab, closeTab)
|
||||
) : null}
|
||||
) : (
|
||||
<>
|
||||
{/* Terminal tabs: always in DOM, absolutely positioned so xterm always has real dimensions */}
|
||||
{tabs
|
||||
.filter((tab) => tab.type === "terminal")
|
||||
.map((tab) => {
|
||||
const visible = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{
|
||||
visibility: visible ? "visible" : "hidden",
|
||||
pointerEvents: visible ? "auto" : "none",
|
||||
zIndex: visible ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
{renderTabContent(
|
||||
tab,
|
||||
openSingletonTab,
|
||||
openTab,
|
||||
closeTab,
|
||||
visible,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Non-terminal tabs: display:none when inactive */}
|
||||
{tabs
|
||||
.filter((tab) => tab.type !== "terminal")
|
||||
.map((tab) => {
|
||||
const visible = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
style={{ display: visible ? undefined : "none" }}
|
||||
>
|
||||
{renderTabContent(
|
||||
tab,
|
||||
openSingletonTab,
|
||||
openTab,
|
||||
closeTab,
|
||||
visible,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
import React from "react";
|
||||
import { useSidebar } from "@/components/sidebar.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/tabs.tsx";
|
||||
import { Shield, Users, Database, Clock, Key } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getAdminOIDCConfig,
|
||||
getRegistrationAllowed,
|
||||
getPasswordLoginAllowed,
|
||||
getPasswordResetAllowed,
|
||||
getUserList,
|
||||
getUserInfo,
|
||||
isElectron,
|
||||
getSessions,
|
||||
unlinkOIDCFromPasswordAccount,
|
||||
} from "@/main-axios.ts";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { RolesTab } from "@/admin/tabs/RolesTab.tsx";
|
||||
import { GeneralSettingsTab } from "@/admin/tabs/GeneralSettingsTab.tsx";
|
||||
import { OIDCSettingsTab } from "@/admin/tabs/OIDCSettingsTab.tsx";
|
||||
import { UserManagementTab } from "@/admin/tabs/UserManagementTab.tsx";
|
||||
import { SessionManagementTab } from "@/admin/tabs/SessionManagementTab.tsx";
|
||||
import { DatabaseSecurityTab } from "@/admin/tabs/DatabaseSecurityTab.tsx";
|
||||
import { ApiKeysTab } from "@/admin/tabs/ApiKeysTab.tsx";
|
||||
import { CreateUserDialog } from "./dialogs/CreateUserDialog.tsx";
|
||||
import { UserEditDialog } from "./dialogs/UserEditDialog.tsx";
|
||||
import { LinkAccountDialog } from "./dialogs/LinkAccountDialog.tsx";
|
||||
|
||||
interface AdminSettingsProps {
|
||||
isTopbarOpen?: boolean;
|
||||
rightSidebarOpen?: boolean;
|
||||
rightSidebarWidth?: number;
|
||||
}
|
||||
|
||||
export function AdminSettings({
|
||||
isTopbarOpen = true,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
}: AdminSettingsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||
const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true);
|
||||
const [allowPasswordReset, setAllowPasswordReset] = React.useState(true);
|
||||
|
||||
const [oidcConfig, setOidcConfig] = React.useState({
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid email profile",
|
||||
userinfo_url: "",
|
||||
allowed_users: "",
|
||||
});
|
||||
|
||||
const [users, setUsers] = React.useState<
|
||||
Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
}>
|
||||
>([]);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
|
||||
const [createUserDialogOpen, setCreateUserDialogOpen] = React.useState(false);
|
||||
const [userEditDialogOpen, setUserEditDialogOpen] = React.useState(false);
|
||||
const [selectedUserForEdit, setSelectedUserForEdit] = React.useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
} | null>(null);
|
||||
|
||||
const [currentUser, setCurrentUser] = React.useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const [sessions, setSessions] = React.useState<
|
||||
Array<{
|
||||
id: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
deviceType: string;
|
||||
deviceInfo: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastActiveAt: string;
|
||||
isRevoked?: boolean;
|
||||
isCurrentSession?: boolean;
|
||||
}>
|
||||
>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = React.useState(false);
|
||||
|
||||
const [linkAccountAlertOpen, setLinkAccountAlertOpen] = React.useState(false);
|
||||
const [linkOidcUser, setLinkOidcUser] = React.useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Promise.allSettled([
|
||||
getAdminOIDCConfig()
|
||||
.then((res) => {
|
||||
if (res)
|
||||
setOidcConfig((prev) => ({
|
||||
...prev,
|
||||
...res,
|
||||
identifier_path: (res.identifier_path as string) || "sub",
|
||||
name_path: (res.name_path as string) || "name",
|
||||
scopes: (res.scopes as string) || "openid email profile",
|
||||
}));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!err.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchOidcConfig"));
|
||||
}
|
||||
}),
|
||||
getUserInfo()
|
||||
.then((info) => {
|
||||
if (info) {
|
||||
setCurrentUser({
|
||||
id: info.userId,
|
||||
username: info.username,
|
||||
is_admin: info.is_admin,
|
||||
is_oidc: info.is_oidc,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!err?.message?.includes("No server configured")) {
|
||||
console.warn("Failed to fetch current user info", err);
|
||||
}
|
||||
}),
|
||||
getSessions()
|
||||
.then((data) => setSessions(data.sessions || []))
|
||||
.catch((err) => {
|
||||
if (!err?.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchSessions"));
|
||||
}
|
||||
}),
|
||||
]).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getRegistrationAllowed()
|
||||
.then((res) => {
|
||||
if (typeof res?.allowed === "boolean") {
|
||||
setAllowRegistration(res.allowed);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!err.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchRegistrationStatus"));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getPasswordLoginAllowed()
|
||||
.then((res) => {
|
||||
if (typeof res?.allowed === "boolean") {
|
||||
setAllowPasswordLogin(res.allowed);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.code !== "NO_SERVER_CONFIGURED") {
|
||||
toast.error(t("admin.failedToFetchPasswordLoginStatus"));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getPasswordResetAllowed()
|
||||
.then((res) => {
|
||||
if (typeof res === "boolean") {
|
||||
setAllowPasswordReset(res);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.code !== "NO_SERVER_CONFIGURED") {
|
||||
console.warn("Failed to fetch password reset status", err);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const response = await getUserList();
|
||||
setUsers(response.users);
|
||||
} catch (err) {
|
||||
if (!err.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchUsers"));
|
||||
}
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditUser = (user: (typeof users)[0]) => {
|
||||
setSelectedUserForEdit(user);
|
||||
setUserEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateUserSuccess = () => {
|
||||
fetchUsers();
|
||||
setCreateUserDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleEditUserSuccess = () => {
|
||||
fetchUsers();
|
||||
setUserEditDialogOpen(false);
|
||||
setSelectedUserForEdit(null);
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const data = await getSessions();
|
||||
setSessions(data.sessions || []);
|
||||
} catch (err) {
|
||||
if (!err?.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchSessions"));
|
||||
}
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLinkOIDCUser = (user: { id: string; username: string }) => {
|
||||
setLinkOidcUser(user);
|
||||
setLinkAccountAlertOpen(true);
|
||||
};
|
||||
|
||||
const handleLinkSuccess = () => {
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
};
|
||||
|
||||
const handleUnlinkOIDC = async (userId: string, username: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.unlinkOIDCDescription", { username }),
|
||||
async () => {
|
||||
try {
|
||||
const result = await unlinkOIDCFromPasswordAccount(userId);
|
||||
|
||||
toast.success(
|
||||
result.message || t("admin.unlinkOIDCSuccess", { username }),
|
||||
);
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: string; code?: string } };
|
||||
};
|
||||
toast.error(
|
||||
err.response?.data?.error || t("admin.failedToUnlinkOIDC"),
|
||||
);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: rightSidebarOpen
|
||||
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
|
||||
: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
transition:
|
||||
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden"
|
||||
>
|
||||
<SimpleLoader visible={loading} message={t("common.loading")} />
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t("admin.title")}</h1>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="px-6 py-4 overflow-auto thin-scrollbar">
|
||||
<Tabs
|
||||
defaultValue="registration"
|
||||
onValueChange={(value) => {
|
||||
if (value === "users") {
|
||||
fetchUsers();
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="mb-4 bg-elevated border-2 border-edge">
|
||||
<TabsTrigger
|
||||
value="registration"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
{t("admin.general")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="oidc"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
OIDC
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="users"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
{t("admin.users")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="sessions"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
Sessions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="roles"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("rbac.roles.label")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Database className="h-4 w-4" />
|
||||
{t("admin.databaseSecurity")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="api-keys"
|
||||
className="flex items-center gap-2 bg-elevated data-[state=active]:bg-button data-[state=active]:border data-[state=active]:border-edge"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
{t("admin.apiKeys.tabLabel")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="registration" className="space-y-6">
|
||||
<GeneralSettingsTab
|
||||
allowRegistration={allowRegistration}
|
||||
setAllowRegistration={setAllowRegistration}
|
||||
allowPasswordLogin={allowPasswordLogin}
|
||||
setAllowPasswordLogin={setAllowPasswordLogin}
|
||||
allowPasswordReset={allowPasswordReset}
|
||||
setAllowPasswordReset={setAllowPasswordReset}
|
||||
oidcConfig={oidcConfig}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="oidc" className="space-y-6">
|
||||
<OIDCSettingsTab
|
||||
allowPasswordLogin={allowPasswordLogin}
|
||||
oidcConfig={oidcConfig}
|
||||
setOidcConfig={setOidcConfig}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users" className="space-y-6">
|
||||
<UserManagementTab
|
||||
users={users}
|
||||
usersLoading={usersLoading}
|
||||
allowPasswordLogin={allowPasswordLogin}
|
||||
fetchUsers={fetchUsers}
|
||||
onCreateUser={() => setCreateUserDialogOpen(true)}
|
||||
onEditUser={handleEditUser}
|
||||
onLinkOIDCUser={handleLinkOIDCUser}
|
||||
onUnlinkOIDC={handleUnlinkOIDC}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sessions" className="space-y-6">
|
||||
<SessionManagementTab
|
||||
sessions={sessions}
|
||||
sessionsLoading={sessionsLoading}
|
||||
fetchSessions={fetchSessions}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="roles" className="space-y-6">
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<RolesTab />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-6">
|
||||
<DatabaseSecurityTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="api-keys" className="space-y-6">
|
||||
<ApiKeysTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateUserDialog
|
||||
open={createUserDialogOpen}
|
||||
onOpenChange={setCreateUserDialogOpen}
|
||||
onSuccess={handleCreateUserSuccess}
|
||||
/>
|
||||
|
||||
<UserEditDialog
|
||||
open={userEditDialogOpen}
|
||||
onOpenChange={setUserEditDialogOpen}
|
||||
user={selectedUserForEdit}
|
||||
currentUser={currentUser}
|
||||
onSuccess={handleEditUserSuccess}
|
||||
/>
|
||||
|
||||
<LinkAccountDialog
|
||||
open={linkAccountAlertOpen}
|
||||
onOpenChange={setLinkAccountAlertOpen}
|
||||
oidcUser={linkOidcUser}
|
||||
onSuccess={handleLinkSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminSettings;
|
||||
@@ -1,163 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserPlus, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { registerUser } from "@/main-axios.ts";
|
||||
|
||||
interface CreateUserDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function CreateUserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: CreateUserDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleCreateUser = async (e?: React.FormEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!username.trim()) {
|
||||
setError(t("admin.enterUsername"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
setError(t("admin.enterPassword"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await registerUser(username.trim(), password);
|
||||
toast.success(
|
||||
t("admin.userCreatedSuccessfully", { username: username.trim() }),
|
||||
);
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
const errorMessage =
|
||||
error?.response?.data?.error || t("admin.failedToCreateUser");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(newOpen) => {
|
||||
if (!loading) {
|
||||
onOpenChange(newOpen);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserPlus className="w-5 h-5" />
|
||||
{t("admin.createUser")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("admin.createUserDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleCreateUser} className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-username">{t("admin.username")}</Label>
|
||||
<Input
|
||||
id="create-username"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("admin.enterUsername")}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-password">{t("common.password")}</Label>
|
||||
<PasswordInput
|
||||
id="create-password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("admin.enterPassword")}
|
||||
disabled={loading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleCreateUser();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.passwordMinLength")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => handleCreateUser()} disabled={loading}>
|
||||
{loading ? t("common.creating") : t("admin.createUser")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { linkOIDCToPasswordAccount } from "@/main-axios.ts";
|
||||
|
||||
interface LinkAccountDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
oidcUser: { id: string; username: string } | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function LinkAccountDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
oidcUser,
|
||||
onSuccess,
|
||||
}: LinkAccountDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [linkTargetUsername, setLinkTargetUsername] = useState("");
|
||||
const [linkLoading, setLinkLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setLinkTargetUsername("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleLinkSubmit = async () => {
|
||||
if (!oidcUser || !linkTargetUsername.trim()) {
|
||||
toast.error("Target username is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLinkLoading(true);
|
||||
try {
|
||||
const result = await linkOIDCToPasswordAccount(
|
||||
oidcUser.id,
|
||||
linkTargetUsername.trim(),
|
||||
);
|
||||
|
||||
toast.success(
|
||||
result.message ||
|
||||
`OIDC user ${oidcUser.username} linked to ${linkTargetUsername}`,
|
||||
);
|
||||
setLinkTargetUsername("");
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: string; code?: string } };
|
||||
};
|
||||
toast.error(err.response?.data?.error || "Failed to link accounts");
|
||||
} finally {
|
||||
setLinkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5" />
|
||||
{t("admin.linkOidcToPasswordAccount")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("admin.linkOidcToPasswordAccountDescription", {
|
||||
username: oidcUser?.username,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("admin.linkOidcWarningTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("admin.linkOidcWarningDescription")}
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>{t("admin.linkOidcActionDeleteUser")}</li>
|
||||
<li>{t("admin.linkOidcActionAddCapability")}</li>
|
||||
<li>{t("admin.linkOidcActionDualAuth")}</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="link-target-username"
|
||||
className="text-base font-semibold text-foreground"
|
||||
>
|
||||
{t("admin.linkTargetUsernameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="link-target-username"
|
||||
value={linkTargetUsername}
|
||||
onChange={(e) => setLinkTargetUsername(e.target.value)}
|
||||
placeholder={t("admin.linkTargetUsernamePlaceholder")}
|
||||
disabled={linkLoading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && linkTargetUsername.trim()) {
|
||||
handleLinkSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={linkLoading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLinkSubmit}
|
||||
disabled={linkLoading || !linkTargetUsername.trim()}
|
||||
variant="destructive"
|
||||
>
|
||||
{linkLoading
|
||||
? t("admin.linkingAccounts")
|
||||
: t("admin.linkAccountsButton")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,538 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Badge } from "@/components/badge.tsx";
|
||||
import { Switch } from "@/components/switch.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
UserCog,
|
||||
Trash2,
|
||||
Plus,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getUserRoles,
|
||||
getRoles,
|
||||
assignRoleToUser,
|
||||
removeRoleFromUser,
|
||||
makeUserAdmin,
|
||||
removeAdminStatus,
|
||||
revokeAllUserSessions,
|
||||
deleteUser,
|
||||
type UserRole,
|
||||
type Role,
|
||||
} from "@/main-axios.ts";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
}
|
||||
|
||||
interface UserEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
user: User | null;
|
||||
currentUser: { id: string; username: string } | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function UserEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
user,
|
||||
currentUser,
|
||||
onSuccess,
|
||||
}: UserEditDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [adminLoading, setAdminLoading] = useState(false);
|
||||
const [sessionLoading, setSessionLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [rolesLoading, setRolesLoading] = useState(false);
|
||||
|
||||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||
const [availableRoles, setAvailableRoles] = useState<Role[]>([]);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const isCurrentUser = user?.id === currentUser?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && user) {
|
||||
setIsAdmin(user.isAdmin);
|
||||
loadRoles();
|
||||
}
|
||||
}, [open, user]);
|
||||
|
||||
const loadRoles = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setRolesLoading(true);
|
||||
try {
|
||||
const [rolesResponse, allRolesResponse] = await Promise.all([
|
||||
getUserRoles(user.id),
|
||||
getRoles(),
|
||||
]);
|
||||
|
||||
setUserRoles(rolesResponse.roles || []);
|
||||
setAvailableRoles(allRolesResponse.roles || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load roles:", error);
|
||||
toast.error(t("rbac.failedToLoadRoles"));
|
||||
} finally {
|
||||
setRolesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAdmin = async (checked: boolean) => {
|
||||
if (!user) return;
|
||||
|
||||
if (isCurrentUser) {
|
||||
toast.error(t("admin.cannotRemoveOwnAdmin"));
|
||||
return;
|
||||
}
|
||||
|
||||
const userToUpdate = user;
|
||||
onOpenChange(false);
|
||||
|
||||
const confirmed = await confirmWithToast({
|
||||
title: checked ? t("admin.makeUserAdmin") : t("admin.removeAdmin"),
|
||||
description: checked
|
||||
? t("admin.confirmMakeAdmin", { username: userToUpdate.username })
|
||||
: t("admin.confirmRemoveAdmin", { username: userToUpdate.username }),
|
||||
confirmText: checked ? t("admin.makeAdmin") : t("admin.removeAdmin"),
|
||||
cancelText: t("common.cancel"),
|
||||
variant: checked ? "default" : "destructive",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setAdminLoading(true);
|
||||
try {
|
||||
if (checked) {
|
||||
await makeUserAdmin(userToUpdate.id);
|
||||
toast.success(
|
||||
t("admin.userIsNowAdmin", { username: userToUpdate.username }),
|
||||
);
|
||||
} else {
|
||||
await removeAdminStatus(userToUpdate.id);
|
||||
toast.success(
|
||||
t("admin.adminStatusRemoved", { username: userToUpdate.username }),
|
||||
);
|
||||
}
|
||||
setIsAdmin(checked);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle admin status:", error);
|
||||
toast.error(
|
||||
checked
|
||||
? t("admin.failedToMakeUserAdmin")
|
||||
: t("admin.failedToRemoveAdminStatus"),
|
||||
);
|
||||
onOpenChange(true);
|
||||
} finally {
|
||||
setAdminLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignRole = async (roleId: number) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
await assignRoleToUser(user.id, roleId);
|
||||
toast.success(
|
||||
t("rbac.roleAssignedSuccessfully", { username: user.username }),
|
||||
);
|
||||
await loadRoles();
|
||||
} catch (error) {
|
||||
console.error("Failed to assign role:", error);
|
||||
toast.error(t("rbac.failedToAssignRole"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRole = async (roleId: number) => {
|
||||
if (!user) return;
|
||||
|
||||
const userToUpdate = user;
|
||||
onOpenChange(false);
|
||||
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("rbac.confirmRemoveRole"),
|
||||
description: t("rbac.confirmRemoveRoleDescription"),
|
||||
confirmText: t("common.remove"),
|
||||
cancelText: t("common.cancel"),
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await removeRoleFromUser(userToUpdate.id, roleId);
|
||||
toast.success(
|
||||
t("rbac.roleRemovedSuccessfully", { username: userToUpdate.username }),
|
||||
);
|
||||
await loadRoles();
|
||||
onOpenChange(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove role:", error);
|
||||
toast.error(t("rbac.failedToRemoveRole"));
|
||||
onOpenChange(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeAllSessions = async () => {
|
||||
if (!user) return;
|
||||
|
||||
const isRevokingSelf = isCurrentUser;
|
||||
|
||||
const userToUpdate = user;
|
||||
onOpenChange(false);
|
||||
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("admin.revokeAllSessions"),
|
||||
description: isRevokingSelf
|
||||
? t("admin.confirmRevokeOwnSessions")
|
||||
: t("admin.confirmRevokeAllSessions"),
|
||||
confirmText: t("admin.revoke"),
|
||||
cancelText: t("common.cancel"),
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionLoading(true);
|
||||
try {
|
||||
const data = await revokeAllUserSessions(userToUpdate.id);
|
||||
toast.success(data.message || t("admin.sessionsRevokedSuccessfully"));
|
||||
|
||||
if (isRevokingSelf) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
onSuccess();
|
||||
onOpenChange(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to revoke sessions:", error);
|
||||
toast.error(t("admin.failedToRevokeSessions"));
|
||||
onOpenChange(true);
|
||||
} finally {
|
||||
setSessionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!user) return;
|
||||
|
||||
if (isCurrentUser) {
|
||||
toast.error(t("admin.cannotDeleteSelf"));
|
||||
return;
|
||||
}
|
||||
|
||||
const userToDelete = user;
|
||||
onOpenChange(false);
|
||||
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("admin.deleteUserTitle"),
|
||||
description: t("admin.deleteUser", { username: userToDelete.username }),
|
||||
confirmText: t("common.delete"),
|
||||
cancelText: t("common.cancel"),
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
await deleteUser(userToDelete.username);
|
||||
toast.success(
|
||||
t("admin.userDeletedSuccessfully", { username: userToDelete.username }),
|
||||
);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete user:", error);
|
||||
toast.error(t("admin.failedToDeleteUser"));
|
||||
onOpenChange(true);
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAuthTypeDisplay = (): string => {
|
||||
if (!user) return "";
|
||||
if (user.isOidc && user.passwordHash) {
|
||||
return t("admin.dualAuth");
|
||||
} else if (user.isOidc) {
|
||||
return t("admin.externalOIDC");
|
||||
} else {
|
||||
return t("admin.localPassword");
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<UserCog className="w-5 h-5" />
|
||||
{t("admin.manageUser")}: {user.username}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("admin.manageUserDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto thin-scrollbar pr-2">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-surface rounded-lg border border-edge">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{t("admin.username")}
|
||||
</Label>
|
||||
<p className="font-medium">{user.username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{t("admin.authType")}
|
||||
</Label>
|
||||
<p className="font-medium">{getAuthTypeDisplay()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{t("admin.adminStatus")}
|
||||
</Label>
|
||||
<p className="font-medium">
|
||||
{isAdmin ? (
|
||||
<Badge variant="secondary">{t("admin.adminBadge")}</Badge>
|
||||
) : (
|
||||
t("admin.regularUser")
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
{t("admin.userId")}
|
||||
</Label>
|
||||
<p className="font-mono text-xs truncate">{user.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("admin.adminPrivileges")}
|
||||
</Label>
|
||||
<div className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{t("admin.administratorRole")}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.administratorRoleDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isAdmin}
|
||||
onCheckedChange={handleToggleAdmin}
|
||||
disabled={isCurrentUser || adminLoading}
|
||||
/>
|
||||
</div>
|
||||
{isCurrentUser && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.cannotModifyOwnAdminStatus")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<UserCog className="h-4 w-4" />
|
||||
{t("rbac.roleManagement")}
|
||||
</Label>
|
||||
|
||||
{rolesLoading ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("rbac.currentRoles")}
|
||||
</Label>
|
||||
{userRoles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic py-2">
|
||||
{t("rbac.noRolesAssigned")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{userRoles.map((role) => (
|
||||
<div
|
||||
key={role.roleId}
|
||||
className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{role.roleDisplayName
|
||||
? t(role.roleDisplayName)
|
||||
: role.roleName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{role.roleName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{role.isSystem && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("rbac.systemRole")}
|
||||
</Badge>
|
||||
)}
|
||||
{!role.isSystem && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveRole(role.roleId)}
|
||||
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("rbac.assignNewRole")}
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableRoles
|
||||
.filter(
|
||||
(role) =>
|
||||
!role.isSystem &&
|
||||
!userRoles.some((ur) => ur.roleId === role.id),
|
||||
)
|
||||
.map((role) => (
|
||||
<Button
|
||||
key={role.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAssignRole(role.id)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{role.displayName ? t(role.displayName) : role.name}
|
||||
</Button>
|
||||
))}
|
||||
{availableRoles.filter(
|
||||
(role) =>
|
||||
!role.isSystem &&
|
||||
!userRoles.some((ur) => ur.roleId === role.id),
|
||||
).length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
{t("rbac.noCustomRolesToAssign")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
{t("admin.sessionManagement")}
|
||||
</Label>
|
||||
<div className="flex items-center justify-between p-3 border border-edge rounded-lg bg-surface">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">
|
||||
{t("admin.revokeAllSessions")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.revokeAllSessionsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleRevokeAllSessions}
|
||||
disabled={sessionLoading}
|
||||
>
|
||||
{sessionLoading ? t("admin.revoking") : t("admin.revoke")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{t("admin.dangerZone")}
|
||||
</Label>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("admin.deleteUserTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("admin.deleteUserWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteUser}
|
||||
disabled={isCurrentUser || deleteLoading}
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{deleteLoading
|
||||
? t("admin.deleting")
|
||||
: `${t("common.delete")} ${user.username}`}
|
||||
</Button>
|
||||
{isCurrentUser && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("admin.cannotDeleteSelf")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,508 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/table.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/popover.tsx";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/command.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Badge } from "@/components/badge.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
getUserList,
|
||||
type ApiKey,
|
||||
type CreatedApiKey,
|
||||
} from "@/main-axios.ts";
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
function UserCombobox({
|
||||
users,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
users: UserOption[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selected = users.find((u) => u.id === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between font-normal"
|
||||
disabled={disabled}
|
||||
>
|
||||
{selected ? selected.username : t("admin.apiKeys.selectUser")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("admin.apiKeys.searchUsers")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("admin.apiKeys.noUsersFound")}</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-y-auto thin-scrollbar">
|
||||
{users.map((user) => (
|
||||
<CommandItem
|
||||
key={user.id}
|
||||
value={user.username}
|
||||
onSelect={() => {
|
||||
onChange(user.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === user.id ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{user.username}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateApiKeyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState("");
|
||||
const [selectedUserId, setSelectedUserId] = React.useState("");
|
||||
const [expiresAt, setExpiresAt] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
const [users, setUsers] = React.useState<UserOption[]>([]);
|
||||
const [createdKey, setCreatedKey] = React.useState<CreatedApiKey | null>(
|
||||
null,
|
||||
);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
if (createdKey) return;
|
||||
|
||||
setUsersLoading(true);
|
||||
getUserList()
|
||||
.then((res) =>
|
||||
setUsers(
|
||||
res.users.map((u) => ({
|
||||
id: (u as unknown as { id: string }).id,
|
||||
username: u.username,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => toast.error(t("admin.failedToFetchUsers")))
|
||||
.finally(() => setUsersLoading(false));
|
||||
}, [open]);
|
||||
|
||||
const handleClose = () => {
|
||||
const wasCreated = createdKey !== null;
|
||||
setCreatedKey(null);
|
||||
setName("");
|
||||
setSelectedUserId("");
|
||||
setExpiresAt("");
|
||||
setCopied(false);
|
||||
onOpenChange(false);
|
||||
if (wasCreated) onCreated();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("admin.apiKeys.nameRequired"));
|
||||
return;
|
||||
}
|
||||
if (!selectedUserId) {
|
||||
toast.error(t("admin.apiKeys.userRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await createApiKey(
|
||||
name.trim(),
|
||||
selectedUserId,
|
||||
expiresAt || undefined,
|
||||
);
|
||||
setCreatedKey(result);
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { error?: string } } };
|
||||
toast.error(
|
||||
e?.response?.data?.error || t("admin.apiKeys.failedToCreate"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!createdKey) return;
|
||||
await navigator.clipboard.writeText(createdKey.token);
|
||||
setCopied(true);
|
||||
toast.success(t("admin.apiKeys.tokenCopied"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
{createdKey
|
||||
? t("admin.apiKeys.keyCreated")
|
||||
: t("admin.apiKeys.createApiKey")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{createdKey
|
||||
? t("admin.apiKeys.keyCreatedDescription")
|
||||
: t("admin.apiKeys.createApiKeyDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!createdKey ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("admin.apiKeys.keyName")}</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("admin.apiKeys.keyNamePlaceholder")}
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("admin.apiKeys.scopedUser")}</Label>
|
||||
{usersLoading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.loading")}
|
||||
</p>
|
||||
) : (
|
||||
<UserCombobox
|
||||
users={users}
|
||||
value={selectedUserId}
|
||||
onChange={setSelectedUserId}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{t("admin.apiKeys.expiresAt")}{" "}
|
||||
<span className="text-muted-foreground text-xs">
|
||||
({t("admin.apiKeys.optional")})
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={expiresAt}
|
||||
onChange={(e) => setExpiresAt(e.target.value)}
|
||||
disabled={loading}
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.apiKeys.expiresAtHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<Alert className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-900 dark:text-yellow-200">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||
<AlertTitle>{t("admin.apiKeys.copyWarningTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("admin.apiKeys.copyWarningDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("admin.apiKeys.apiKey")}</Label>
|
||||
<div className="flex gap-2 items-start">
|
||||
<code className="flex-1 block rounded bg-muted px-3 py-2 text-xs font-mono break-all border border-edge">
|
||||
{createdKey.token}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{!createdKey ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={loading || usersLoading}>
|
||||
{loading
|
||||
? t("admin.apiKeys.creating")
|
||||
: t("admin.apiKeys.createApiKey")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={handleClose}>{t("common.done")}</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiKeysTab(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const [keys, setKeys] = React.useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
|
||||
|
||||
const fetchKeys = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getApiKeys();
|
||||
setKeys(data.apiKeys);
|
||||
} catch {
|
||||
toast.error(t("admin.apiKeys.failedToFetch"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchKeys();
|
||||
}, [fetchKeys]);
|
||||
|
||||
const handleDelete = (keyId: string, keyName: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.apiKeys.confirmRevoke", { name: keyName }),
|
||||
async () => {
|
||||
try {
|
||||
await deleteApiKey(keyId);
|
||||
toast.success(t("admin.apiKeys.revokedSuccessfully"));
|
||||
fetchKeys();
|
||||
} catch {
|
||||
toast.error(t("admin.apiKeys.failedToRevoke"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return t("admin.apiKeys.never");
|
||||
const d = new Date(iso);
|
||||
return (
|
||||
d.toLocaleDateString() +
|
||||
" " +
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
);
|
||||
};
|
||||
|
||||
const isExpired = (expiresAt: string | null) =>
|
||||
expiresAt ? new Date(expiresAt) < new Date() : false;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{t("admin.apiKeys.title")}</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() =>
|
||||
window.open("https://docs.termix.site/api-keys", "_blank")
|
||||
}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={fetchKeys}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("h-4 w-4 mr-1", loading && "animate-spin")}
|
||||
/>
|
||||
{loading ? t("admin.loading") : t("admin.refresh")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t("admin.apiKeys.createApiKey")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && keys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.loading")}
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.apiKeys.noKeys")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.apiKeys.name")}</TableHead>
|
||||
<TableHead>{t("admin.user")}</TableHead>
|
||||
<TableHead>{t("admin.apiKeys.prefix")}</TableHead>
|
||||
<TableHead>{t("admin.created")}</TableHead>
|
||||
<TableHead>{t("admin.expires")}</TableHead>
|
||||
<TableHead>{t("admin.apiKeys.lastUsed")}</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{keys.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="px-4 font-medium">
|
||||
{key.name}
|
||||
{!key.isActive && (
|
||||
<Badge variant="destructive" className="ml-2 text-xs">
|
||||
{t("admin.revoked")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{key.username || key.userId}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded">
|
||||
{key.tokenPrefix}…
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(key.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm">
|
||||
<span
|
||||
className={
|
||||
isExpired(key.expiresAt)
|
||||
? "text-red-500"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{formatDate(key.expiresAt)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(key.lastUsedAt)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title={t("admin.apiKeys.revokeKey")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<CreateApiKeyDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onCreated={fetchKeys}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Download, Upload } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { isElectron } from "@/main-axios.ts";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
|
||||
export function DatabaseSecurityTab(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [exportLoading, setExportLoading] = React.useState(false);
|
||||
const [importLoading, setImportLoading] = React.useState(false);
|
||||
const [importFile, setImportFile] = React.useState<File | null>(null);
|
||||
|
||||
const handleExportDatabase = async () => {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "" ||
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/export`
|
||||
: isDev
|
||||
? `http://localhost:30001/database/export`
|
||||
: `${window.location.protocol}//${window.location.host}${getBasePath()}/database/export`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get("content-disposition");
|
||||
const filename =
|
||||
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
|
||||
"termix-export.sqlite";
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast.success(t("admin.databaseExportedSuccessfully"));
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || t("admin.databaseExportFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.databaseExportFailed"));
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportDatabase = async () => {
|
||||
if (!importFile) {
|
||||
toast.error(t("admin.pleaseSelectImportFile"));
|
||||
return;
|
||||
}
|
||||
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "" ||
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/import`
|
||||
: isDev
|
||||
? `http://localhost:30001/database/import`
|
||||
: `${window.location.protocol}//${window.location.host}${getBasePath()}/database/import`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const summary = result.summary;
|
||||
const imported =
|
||||
summary.sshHostsImported +
|
||||
summary.sshCredentialsImported +
|
||||
summary.fileManagerItemsImported +
|
||||
summary.dismissedAlertsImported +
|
||||
(summary.settingsImported || 0);
|
||||
const skipped = summary.skippedItems;
|
||||
|
||||
const details = [];
|
||||
if (summary.sshHostsImported > 0)
|
||||
details.push(`${summary.sshHostsImported} SSH hosts`);
|
||||
if (summary.sshCredentialsImported > 0)
|
||||
details.push(`${summary.sshCredentialsImported} credentials`);
|
||||
if (summary.fileManagerItemsImported > 0)
|
||||
details.push(
|
||||
`${summary.fileManagerItemsImported} file manager items`,
|
||||
);
|
||||
if (summary.dismissedAlertsImported > 0)
|
||||
details.push(`${summary.dismissedAlertsImported} alerts`);
|
||||
if (summary.settingsImported > 0)
|
||||
details.push(`${summary.settingsImported} settings`);
|
||||
|
||||
toast.success(
|
||||
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`,
|
||||
);
|
||||
setImportFile(null);
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
toast.error(
|
||||
`${t("admin.databaseImportFailed")}: ${result.summary?.errors?.join(", ") || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(error.error || t("admin.databaseImportFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.databaseImportFailed"));
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.databaseSecurity")}</h3>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="p-4 border rounded-lg bg-surface">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-blue-500" />
|
||||
<h4 className="font-semibold">{t("admin.export")}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.exportDescription")}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleExportDatabase}
|
||||
disabled={exportLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{exportLoading ? t("admin.exporting") : t("admin.export")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border rounded-lg bg-surface">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 text-green-500" />
|
||||
<h4 className="font-semibold">{t("admin.import")}</h4>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.importDescription")}
|
||||
</p>
|
||||
<div className="relative inline-block w-full mb-2">
|
||||
<input
|
||||
id="import-file-upload"
|
||||
type="file"
|
||||
accept=".sqlite,.db"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={importFile?.name || t("admin.pleaseSelectImportFile")}
|
||||
>
|
||||
{importFile
|
||||
? importFile.name
|
||||
: t("admin.pleaseSelectImportFile")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleImportDatabase}
|
||||
disabled={importLoading || !importFile}
|
||||
className="w-full"
|
||||
>
|
||||
{importLoading ? t("admin.importing") : t("admin.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,536 +0,0 @@
|
||||
import React from "react";
|
||||
import { Checkbox } from "@/components/checkbox.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/select.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
updateRegistrationAllowed,
|
||||
updatePasswordLoginAllowed,
|
||||
updatePasswordResetAllowed,
|
||||
getGlobalMonitoringSettings,
|
||||
updateGlobalMonitoringSettings,
|
||||
getGuacamoleSettings,
|
||||
updateGuacamoleSettings,
|
||||
getLogLevel,
|
||||
updateLogLevel,
|
||||
getSessionTimeout,
|
||||
updateSessionTimeout,
|
||||
} from "@/main-axios.ts";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
|
||||
interface GeneralSettingsTabProps {
|
||||
allowRegistration: boolean;
|
||||
setAllowRegistration: (value: boolean) => void;
|
||||
allowPasswordLogin: boolean;
|
||||
setAllowPasswordLogin: (value: boolean) => void;
|
||||
allowPasswordReset: boolean;
|
||||
setAllowPasswordReset: (value: boolean) => void;
|
||||
oidcConfig: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function GeneralSettingsTab({
|
||||
allowRegistration,
|
||||
setAllowRegistration,
|
||||
allowPasswordLogin,
|
||||
setAllowPasswordLogin,
|
||||
allowPasswordReset,
|
||||
setAllowPasswordReset,
|
||||
oidcConfig,
|
||||
}: GeneralSettingsTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [regLoading, setRegLoading] = React.useState(false);
|
||||
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
|
||||
const [passwordResetLoading, setPasswordResetLoading] = React.useState(false);
|
||||
|
||||
const [statusInterval, setStatusInterval] = React.useState(60);
|
||||
const [metricsInterval, setMetricsInterval] = React.useState(30);
|
||||
const [statusInputValue, setStatusInputValue] = React.useState("60");
|
||||
const [metricsInputValue, setMetricsInputValue] = React.useState("30");
|
||||
const [statusUnit, setStatusUnit] = React.useState<"seconds" | "minutes">(
|
||||
"seconds",
|
||||
);
|
||||
const [metricsUnit, setMetricsUnit] = React.useState<"seconds" | "minutes">(
|
||||
"seconds",
|
||||
);
|
||||
const [monitoringLoading, setMonitoringLoading] = React.useState(false);
|
||||
|
||||
const [logLevel, setLogLevel] = React.useState("info");
|
||||
const [logLevelLoading, setLogLevelLoading] = React.useState(false);
|
||||
|
||||
const [, setSessionTimeoutHours] = React.useState(24);
|
||||
const [sessionTimeoutInput, setSessionTimeoutInput] = React.useState("24");
|
||||
const [sessionTimeoutLoading, setSessionTimeoutLoading] =
|
||||
React.useState(false);
|
||||
|
||||
const [guacEnabled, setGuacEnabled] = React.useState(true);
|
||||
const [guacUrl, setGuacUrl] = React.useState("guacd:4822");
|
||||
const [guacLoading, setGuacLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
getLogLevel()
|
||||
.then((data) => {
|
||||
setLogLevel(data.level);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
getSessionTimeout()
|
||||
.then((data) => {
|
||||
setSessionTimeoutHours(data.timeoutHours);
|
||||
setSessionTimeoutInput(String(data.timeoutHours));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleLogLevelChange = async (value: string) => {
|
||||
setLogLevel(value);
|
||||
setLogLevelLoading(true);
|
||||
try {
|
||||
await updateLogLevel(value);
|
||||
toast.success(t("admin.logLevelSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.failedToSaveLogLevel"));
|
||||
} finally {
|
||||
setLogLevelLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSessionTimeoutBlur = async () => {
|
||||
const num = parseInt(sessionTimeoutInput) || 24;
|
||||
const clamped = Math.max(1, Math.min(720, num));
|
||||
setSessionTimeoutHours(clamped);
|
||||
setSessionTimeoutInput(String(clamped));
|
||||
setSessionTimeoutLoading(true);
|
||||
try {
|
||||
await updateSessionTimeout(clamped);
|
||||
toast.success(t("admin.sessionTimeoutSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.failedToSaveSessionTimeout"));
|
||||
} finally {
|
||||
setSessionTimeoutLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
getGuacamoleSettings()
|
||||
.then((data) => {
|
||||
setGuacEnabled(data.enabled);
|
||||
setGuacUrl(data.url);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("admin.failedToLoadGuacamoleSettings"));
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
const saveGuacDebounce = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const saveGuacSettings = React.useCallback(
|
||||
(newEnabled: boolean, newUrl: string) => {
|
||||
if (saveGuacDebounce.current) {
|
||||
clearTimeout(saveGuacDebounce.current);
|
||||
}
|
||||
saveGuacDebounce.current = setTimeout(async () => {
|
||||
setGuacLoading(true);
|
||||
try {
|
||||
await updateGuacamoleSettings({ enabled: newEnabled, url: newUrl });
|
||||
toast.success(t("admin.guacamoleSettingsSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.failedToSaveGuacamoleSettings"));
|
||||
} finally {
|
||||
setGuacLoading(false);
|
||||
}
|
||||
}, 800);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
getGlobalMonitoringSettings()
|
||||
.then((data) => {
|
||||
setStatusInterval(data.statusCheckInterval);
|
||||
setMetricsInterval(data.metricsInterval);
|
||||
setStatusInputValue(String(data.statusCheckInterval));
|
||||
setMetricsInputValue(String(data.metricsInterval));
|
||||
})
|
||||
.catch(() => {
|
||||
// Use defaults silently
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveMonitoringSettings = React.useCallback(
|
||||
async (newStatus: number, newMetrics: number) => {
|
||||
setMonitoringLoading(true);
|
||||
try {
|
||||
await updateGlobalMonitoringSettings({
|
||||
statusCheckInterval: newStatus,
|
||||
metricsInterval: newMetrics,
|
||||
});
|
||||
toast.success(t("admin.globalSettingsSaved"));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("admin.failedToSaveGlobalSettings");
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setMonitoringLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleStatusBlur = () => {
|
||||
const num = parseInt(statusInputValue) || 0;
|
||||
const seconds = statusUnit === "minutes" ? num * 60 : num;
|
||||
const clamped = Math.max(5, Math.min(3600, seconds));
|
||||
setStatusInterval(clamped);
|
||||
setStatusInputValue(
|
||||
statusUnit === "minutes"
|
||||
? String(Math.round(clamped / 60))
|
||||
: String(clamped),
|
||||
);
|
||||
saveMonitoringSettings(clamped, metricsInterval);
|
||||
};
|
||||
|
||||
const handleMetricsBlur = () => {
|
||||
const num = parseInt(metricsInputValue) || 0;
|
||||
const seconds = metricsUnit === "minutes" ? num * 60 : num;
|
||||
const clamped = Math.max(5, Math.min(3600, seconds));
|
||||
setMetricsInterval(clamped);
|
||||
setMetricsInputValue(
|
||||
metricsUnit === "minutes"
|
||||
? String(Math.round(clamped / 60))
|
||||
: String(clamped),
|
||||
);
|
||||
saveMonitoringSettings(statusInterval, clamped);
|
||||
};
|
||||
|
||||
const handleToggleRegistration = async (checked: boolean) => {
|
||||
setRegLoading(true);
|
||||
try {
|
||||
await updateRegistrationAllowed(checked);
|
||||
setAllowRegistration(checked);
|
||||
} finally {
|
||||
setRegLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePasswordLogin = async (checked: boolean) => {
|
||||
if (!checked) {
|
||||
const hasOIDCConfigured =
|
||||
oidcConfig.client_id &&
|
||||
oidcConfig.client_secret &&
|
||||
oidcConfig.issuer_url &&
|
||||
oidcConfig.authorization_url &&
|
||||
oidcConfig.token_url;
|
||||
|
||||
if (!hasOIDCConfigured) {
|
||||
toast.error(t("admin.cannotDisablePasswordLoginWithoutOIDC"), {
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmDisablePasswordLogin"),
|
||||
async () => {
|
||||
setPasswordLoginLoading(true);
|
||||
try {
|
||||
await updatePasswordLoginAllowed(checked);
|
||||
setAllowPasswordLogin(checked);
|
||||
|
||||
if (allowRegistration) {
|
||||
await updateRegistrationAllowed(false);
|
||||
setAllowRegistration(false);
|
||||
toast.success(t("admin.passwordLoginAndRegistrationDisabled"));
|
||||
} else {
|
||||
toast.success(t("admin.passwordLoginDisabled"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.failedToUpdatePasswordLoginStatus"));
|
||||
} finally {
|
||||
setPasswordLoginLoading(false);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordLoginLoading(true);
|
||||
try {
|
||||
await updatePasswordLoginAllowed(checked);
|
||||
setAllowPasswordLogin(checked);
|
||||
} finally {
|
||||
setPasswordLoginLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePasswordReset = async (checked: boolean) => {
|
||||
setPasswordResetLoading(true);
|
||||
try {
|
||||
await updatePasswordResetAllowed(checked);
|
||||
setAllowPasswordReset(checked);
|
||||
} finally {
|
||||
setPasswordResetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.userRegistration")}</h3>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowRegistration}
|
||||
onCheckedChange={handleToggleRegistration}
|
||||
disabled={regLoading || !allowPasswordLogin}
|
||||
/>
|
||||
{t("admin.allowNewAccountRegistration")}
|
||||
{!allowPasswordLogin && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("admin.requiresPasswordLogin")})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowPasswordLogin}
|
||||
onCheckedChange={handleTogglePasswordLogin}
|
||||
disabled={passwordLoginLoading}
|
||||
/>
|
||||
{t("admin.allowPasswordLogin")}
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowPasswordReset}
|
||||
onCheckedChange={handleTogglePasswordReset}
|
||||
disabled={passwordResetLoading || !allowPasswordLogin}
|
||||
/>
|
||||
{t("admin.allowPasswordReset")}
|
||||
{!allowPasswordLogin && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("admin.requiresPasswordLogin")})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
{t("admin.allowPasswordResetDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.sessionTimeout")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.sessionTimeoutDesc")}
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t("admin.sessionTimeoutHours")}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={720}
|
||||
value={sessionTimeoutInput}
|
||||
onChange={(e) => setSessionTimeoutInput(e.target.value)}
|
||||
onBlur={handleSessionTimeoutBlur}
|
||||
disabled={sessionTimeoutLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-sm font-medium py-2">{t("admin.hours")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.sessionTimeoutNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.monitoringDefaults")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.monitoringDefaultsDesc")}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t("admin.globalStatusCheckInterval")}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={statusInputValue}
|
||||
onChange={(e) => setStatusInputValue(e.target.value)}
|
||||
onBlur={handleStatusBlur}
|
||||
disabled={monitoringLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={statusUnit}
|
||||
onValueChange={(value: "seconds" | "minutes") => {
|
||||
setStatusUnit(value);
|
||||
setStatusInputValue(
|
||||
value === "minutes"
|
||||
? String(Math.round(statusInterval / 60))
|
||||
: String(statusInterval),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="seconds">
|
||||
{t("hosts.intervalSeconds")}
|
||||
</SelectItem>
|
||||
<SelectItem value="minutes">
|
||||
{t("hosts.intervalMinutes")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t("admin.globalMetricsInterval")}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={metricsInputValue}
|
||||
onChange={(e) => setMetricsInputValue(e.target.value)}
|
||||
onBlur={handleMetricsBlur}
|
||||
disabled={monitoringLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={metricsUnit}
|
||||
onValueChange={(value: "seconds" | "minutes") => {
|
||||
setMetricsUnit(value);
|
||||
setMetricsInputValue(
|
||||
value === "minutes"
|
||||
? String(Math.round(metricsInterval / 60))
|
||||
: String(metricsInterval),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="seconds">
|
||||
{t("hosts.intervalSeconds")}
|
||||
</SelectItem>
|
||||
<SelectItem value="minutes">
|
||||
{t("hosts.intervalMinutes")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.guacamoleIntegration")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.guacamoleIntegrationDesc")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() =>
|
||||
window.open("https://docs.termix.site/remote-desktop", "_blank")
|
||||
}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={guacEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
const val = checked === true;
|
||||
setGuacEnabled(val);
|
||||
saveGuacSettings(val, guacUrl);
|
||||
}}
|
||||
disabled={guacLoading}
|
||||
/>
|
||||
{t("admin.enableGuacamole")}
|
||||
</label>
|
||||
{guacEnabled && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("admin.guacdUrl")}</label>
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={guacUrl}
|
||||
placeholder={t("admin.guacdUrlPlaceholder")}
|
||||
disabled={guacLoading}
|
||||
onChange={(e) => {
|
||||
setGuacUrl(e.target.value);
|
||||
saveGuacSettings(guacEnabled, e.target.value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.guacdUrlNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("admin.logLevel")}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.logLevelDesc")}
|
||||
</p>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t("admin.logVerbosity")}
|
||||
</label>
|
||||
<Select
|
||||
value={logLevel}
|
||||
onValueChange={handleLogLevelChange}
|
||||
disabled={logLevelLoading}
|
||||
>
|
||||
<SelectTrigger className="w-[200px] mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="debug">Debug</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
<SelectItem value="warn">Warning</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.logLevelNote")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { Textarea } from "@/components/textarea.tsx";
|
||||
import { updateOIDCConfig, disableOIDCConfig } from "@/main-axios.ts";
|
||||
|
||||
interface OIDCSettingsTabProps {
|
||||
allowPasswordLogin: boolean;
|
||||
oidcConfig: {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
userinfo_url: string;
|
||||
allowed_users: string;
|
||||
};
|
||||
setOidcConfig: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
userinfo_url: string;
|
||||
allowed_users: string;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export function OIDCSettingsTab({
|
||||
allowPasswordLogin,
|
||||
oidcConfig,
|
||||
setOidcConfig,
|
||||
}: OIDCSettingsTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [oidcLoading, setOidcLoading] = React.useState(false);
|
||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||
|
||||
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setOidcLoading(true);
|
||||
setOidcError(null);
|
||||
|
||||
const required = [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"issuer_url",
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
];
|
||||
const missing = required.filter(
|
||||
(f) => !oidcConfig[f as keyof typeof oidcConfig],
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
setOidcError(
|
||||
t("admin.missingRequiredFields", { fields: missing.join(", ") }),
|
||||
);
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateOIDCConfig(oidcConfig);
|
||||
toast.success(t("admin.oidcConfigurationUpdated"));
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data
|
||||
?.error || t("admin.failedToUpdateOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOIDCConfigChange = (field: string, value: string) => {
|
||||
setOidcConfig((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleResetConfig = async () => {
|
||||
if (!allowPasswordLogin) {
|
||||
confirmWithToast(
|
||||
t("admin.confirmDisableOIDCWarning"),
|
||||
async () => {
|
||||
const emptyConfig = {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "",
|
||||
name_path: "",
|
||||
scopes: "",
|
||||
userinfo_url: "",
|
||||
allowed_users: "",
|
||||
};
|
||||
setOidcConfig(emptyConfig);
|
||||
setOidcError(null);
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
await disableOIDCConfig();
|
||||
toast.success(t("admin.oidcConfigurationDisabled"));
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
(
|
||||
err as {
|
||||
response?: { data?: { error?: string } };
|
||||
}
|
||||
)?.response?.data?.error || t("admin.failedToDisableOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyConfig = {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "",
|
||||
name_path: "",
|
||||
scopes: "",
|
||||
userinfo_url: "",
|
||||
allowed_users: "",
|
||||
};
|
||||
setOidcConfig(emptyConfig);
|
||||
setOidcError(null);
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
await disableOIDCConfig();
|
||||
toast.success(t("admin.oidcConfigurationDisabled"));
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
(
|
||||
err as {
|
||||
response?: { data?: { error?: string } };
|
||||
}
|
||||
)?.response?.data?.error || t("admin.failedToDisableOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-3">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.externalAuthentication")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.configureExternalProvider")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => window.open("https://docs.termix.site/oidc", "_blank")}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!allowPasswordLogin && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("admin.criticalWarning")}</AlertTitle>
|
||||
<AlertDescription>{t("admin.oidcRequiredWarning")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{oidcError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_id">{t("admin.clientId")}</Label>
|
||||
<Input
|
||||
id="client_id"
|
||||
value={oidcConfig.client_id}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("client_id", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.clientId")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_secret">{t("admin.clientSecret")}</Label>
|
||||
<PasswordInput
|
||||
id="client_secret"
|
||||
value={oidcConfig.client_secret}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("client_secret", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.clientSecret")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorization_url">
|
||||
{t("admin.authorizationUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="authorization_url"
|
||||
value={oidcConfig.authorization_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("authorization_url", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.authUrl")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer_url">{t("admin.issuerUrl")}</Label>
|
||||
<Input
|
||||
id="issuer_url"
|
||||
value={oidcConfig.issuer_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("issuer_url", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.redirectUrl")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token_url">{t("admin.tokenUrl")}</Label>
|
||||
<Input
|
||||
id="token_url"
|
||||
value={oidcConfig.token_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("token_url", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.tokenUrl")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier_path">
|
||||
{t("admin.userIdentifierPath")}
|
||||
</Label>
|
||||
<Input
|
||||
id="identifier_path"
|
||||
value={oidcConfig.identifier_path}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("identifier_path", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.userIdField")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name_path">{t("admin.displayNamePath")}</Label>
|
||||
<Input
|
||||
id="name_path"
|
||||
value={oidcConfig.name_path}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("name_path", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.usernameField")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">{t("admin.scopes")}</Label>
|
||||
<Input
|
||||
id="scopes"
|
||||
value={oidcConfig.scopes}
|
||||
onChange={(e) => handleOIDCConfigChange("scopes", e.target.value)}
|
||||
placeholder={t("placeholders.scopes")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userinfo_url">{t("admin.overrideUserInfoUrl")}</Label>
|
||||
<Input
|
||||
id="userinfo_url"
|
||||
value={oidcConfig.userinfo_url}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("userinfo_url", e.target.value)
|
||||
}
|
||||
placeholder="https://your-provider.com/application/o/userinfo/"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="allowed_users">{t("admin.allowedUsers")}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.allowedUsersDescription")}
|
||||
</p>
|
||||
<Textarea
|
||||
id="allowed_users"
|
||||
value={oidcConfig.allowed_users}
|
||||
onChange={(e) =>
|
||||
handleOIDCConfigChange("allowed_users", e.target.value)
|
||||
}
|
||||
placeholder={t("placeholders.allowedUsers")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" className="flex-1" disabled={oidcLoading}>
|
||||
{oidcLoading ? t("admin.saving") : t("admin.saveConfiguration")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleResetConfig}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{t("admin.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/table.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Textarea } from "@/components/textarea.tsx";
|
||||
import { Badge } from "@/components/badge.tsx";
|
||||
import { Shield, Plus, Edit, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getRoles,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
type Role,
|
||||
} from "@/main-axios.ts";
|
||||
|
||||
export function RolesTab(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [roles, setRoles] = React.useState<Role[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const [roleDialogOpen, setRoleDialogOpen] = React.useState(false);
|
||||
const [editingRole, setEditingRole] = React.useState<Role | null>(null);
|
||||
const [roleName, setRoleName] = React.useState("");
|
||||
const [roleDisplayName, setRoleDisplayName] = React.useState("");
|
||||
const [roleDescription, setRoleDescription] = React.useState("");
|
||||
|
||||
const loadRoles = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getRoles();
|
||||
setRoles(response.roles || []);
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToLoadRoles"));
|
||||
console.error("Failed to load roles:", error);
|
||||
setRoles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadRoles();
|
||||
}, [loadRoles]);
|
||||
|
||||
const handleCreateRole = () => {
|
||||
setEditingRole(null);
|
||||
setRoleName("");
|
||||
setRoleDisplayName("");
|
||||
setRoleDescription("");
|
||||
setRoleDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditRole = (role: Role) => {
|
||||
setEditingRole(role);
|
||||
setRoleName(role.name);
|
||||
setRoleDisplayName(role.displayName);
|
||||
setRoleDescription(role.description || "");
|
||||
setRoleDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveRole = async () => {
|
||||
if (!roleDisplayName.trim()) {
|
||||
toast.error(t("rbac.roleDisplayNameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingRole && !roleName.trim()) {
|
||||
toast.error(t("rbac.roleNameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingRole) {
|
||||
await updateRole(editingRole.id, {
|
||||
displayName: roleDisplayName,
|
||||
description: roleDescription || null,
|
||||
});
|
||||
toast.success(t("rbac.roleUpdatedSuccessfully"));
|
||||
} else {
|
||||
await createRole({
|
||||
name: roleName,
|
||||
displayName: roleDisplayName,
|
||||
description: roleDescription || null,
|
||||
});
|
||||
toast.success(t("rbac.roleCreatedSuccessfully"));
|
||||
}
|
||||
|
||||
setRoleDialogOpen(false);
|
||||
loadRoles();
|
||||
} catch {
|
||||
toast.error(t("rbac.failedToSaveRole"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRole = async (role: Role) => {
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("rbac.confirmDeleteRole"),
|
||||
description: t("rbac.confirmDeleteRoleDescription", {
|
||||
name: role.displayName,
|
||||
}),
|
||||
confirmText: t("common.delete"),
|
||||
cancelText: t("common.cancel"),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await deleteRole(role.id);
|
||||
toast.success(t("rbac.roleDeletedSuccessfully"));
|
||||
loadRoles();
|
||||
} catch {
|
||||
toast.error(t("rbac.failedToDeleteRole"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
{t("rbac.roleManagement")}
|
||||
</h3>
|
||||
<Button onClick={handleCreateRole}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("rbac.createRole")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() => window.open("https://docs.termix.site/rbac", "_blank")}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("rbac.roleName")}</TableHead>
|
||||
<TableHead>{t("rbac.displayName")}</TableHead>
|
||||
<TableHead>{t("rbac.description")}</TableHead>
|
||||
<TableHead>{t("rbac.type")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("common.loading")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("rbac.noRoles")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell className="font-mono">{role.name}</TableCell>
|
||||
<TableCell>
|
||||
{role.displayName ? t(role.displayName) : role.name}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{role.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{role.isSystem ? (
|
||||
<Badge variant="secondary">{t("rbac.systemRole")}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("rbac.customRole")}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{!role.isSystem && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditRole(role)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteRole(role)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingRole ? t("rbac.editRole") : t("rbac.createRole")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingRole
|
||||
? t("rbac.editRoleDescription")
|
||||
: t("rbac.createRoleDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{!editingRole && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-name">{t("rbac.roleName")}</Label>
|
||||
<Input
|
||||
id="role-name"
|
||||
value={roleName}
|
||||
onChange={(e) => setRoleName(e.target.value.toLowerCase())}
|
||||
placeholder="developer"
|
||||
disabled={!!editingRole}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("rbac.roleNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-display-name">{t("rbac.displayName")}</Label>
|
||||
<Input
|
||||
id="role-display-name"
|
||||
value={roleDisplayName}
|
||||
onChange={(e) => setRoleDisplayName(e.target.value)}
|
||||
placeholder={t("rbac.displayNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-description">{t("rbac.description")}</Label>
|
||||
<Textarea
|
||||
id="role-description"
|
||||
value={roleDescription}
|
||||
onChange={(e) => setRoleDescription(e.target.value)}
|
||||
placeholder={t("rbac.descriptionPlaceholder")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSaveRole}>
|
||||
{editingRole ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/table.tsx";
|
||||
import { Monitor, Smartphone, Globe, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { revokeSession, revokeAllUserSessions } from "@/main-axios.ts";
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
deviceType: string;
|
||||
deviceInfo: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastActiveAt: string;
|
||||
isRevoked?: boolean;
|
||||
isCurrentSession?: boolean;
|
||||
}
|
||||
|
||||
interface SessionManagementTabProps {
|
||||
sessions: Session[];
|
||||
sessionsLoading: boolean;
|
||||
fetchSessions: () => void;
|
||||
}
|
||||
|
||||
export function SessionManagementTab({
|
||||
sessions,
|
||||
sessionsLoading,
|
||||
fetchSessions,
|
||||
}: SessionManagementTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const handleRevokeSession = async (sessionId: string) => {
|
||||
const isCurrentSession = sessions.some(
|
||||
(session) => session.id === sessionId && session.isCurrentSession,
|
||||
);
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmRevokeSession"),
|
||||
async () => {
|
||||
try {
|
||||
await revokeSession(sessionId);
|
||||
toast.success(t("admin.sessionRevokedSuccessfully"));
|
||||
|
||||
if (isCurrentSession) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
fetchSessions();
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.failedToRevokeSession"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const handleRevokeAllUserSessions = async (userId: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.confirmRevokeAllSessions"),
|
||||
async () => {
|
||||
try {
|
||||
const data = await revokeAllUserSessions(userId);
|
||||
toast.success(data.message || t("admin.sessionsRevokedSuccessfully"));
|
||||
fetchSessions();
|
||||
} catch {
|
||||
toast.error(t("admin.failedToRevokeSessions"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString() +
|
||||
" " +
|
||||
date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("admin.sessionManagement")}
|
||||
</h3>
|
||||
<Button
|
||||
onClick={fetchSessions}
|
||||
disabled={sessionsLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{sessionsLoading ? t("admin.loading") : t("admin.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
{sessionsLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.loadingSessions")}
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.noActiveSessions")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.device")}</TableHead>
|
||||
<TableHead>{t("admin.user")}</TableHead>
|
||||
<TableHead>{t("admin.created")}</TableHead>
|
||||
<TableHead>{t("admin.lastActive")}</TableHead>
|
||||
<TableHead>{t("admin.expires")}</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((session) => {
|
||||
const DeviceIcon =
|
||||
session.deviceType === "desktop"
|
||||
? Monitor
|
||||
: session.deviceType === "mobile"
|
||||
? Smartphone
|
||||
: Globe;
|
||||
|
||||
const createdDate = new Date(session.createdAt);
|
||||
const lastActiveDate = new Date(session.lastActiveAt);
|
||||
const expiresDate = new Date(session.expiresAt);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
className={session.isRevoked ? "opacity-50" : undefined}
|
||||
>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DeviceIcon className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm">
|
||||
{session.deviceInfo}
|
||||
</span>
|
||||
{session.isRevoked && (
|
||||
<span className="text-xs text-red-600">
|
||||
{t("admin.revoked")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{session.username || session.userId}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(createdDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(lastActiveDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(expiresDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={session.isRevoked}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{session.username && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeAllUserSessions(session.userId)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
|
||||
title={t("admin.revokeAllUserSessionsTitle")}
|
||||
>
|
||||
{t("admin.revokeAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/table.tsx";
|
||||
import { UserPlus, Edit, Trash2, Link2, Unlink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { deleteUser } from "@/main-axios.ts";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
}
|
||||
|
||||
interface UserManagementTabProps {
|
||||
users: User[];
|
||||
usersLoading: boolean;
|
||||
allowPasswordLogin: boolean;
|
||||
fetchUsers: () => void;
|
||||
onCreateUser: () => void;
|
||||
onEditUser: (user: User) => void;
|
||||
onLinkOIDCUser: (user: { id: string; username: string }) => void;
|
||||
onUnlinkOIDC: (userId: string, username: string) => void;
|
||||
}
|
||||
|
||||
export function UserManagementTab({
|
||||
users,
|
||||
usersLoading,
|
||||
allowPasswordLogin,
|
||||
fetchUsers,
|
||||
onCreateUser,
|
||||
onEditUser,
|
||||
onLinkOIDCUser,
|
||||
onUnlinkOIDC,
|
||||
}: UserManagementTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const getAuthTypeDisplay = (user: User): string => {
|
||||
if (user.isOidc && user.passwordHash) {
|
||||
return t("admin.dualAuth");
|
||||
} else if (user.isOidc) {
|
||||
return t("admin.externalOIDC");
|
||||
} else {
|
||||
return t("admin.localPassword");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUserQuick = async (username: string) => {
|
||||
confirmWithToast(
|
||||
t("admin.deleteUser", { username }),
|
||||
async () => {
|
||||
try {
|
||||
await deleteUser(username);
|
||||
toast.success(t("admin.userDeletedSuccessfully", { username }));
|
||||
fetchUsers();
|
||||
} catch {
|
||||
toast.error(t("admin.failedToDeleteUser"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">{t("admin.userManagement")}</h3>
|
||||
<div className="flex gap-2">
|
||||
{allowPasswordLogin && (
|
||||
<Button onClick={onCreateUser} size="sm">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
{t("admin.createUser")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={fetchUsers}
|
||||
disabled={usersLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{usersLoading ? t("admin.loading") : t("admin.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{usersLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("admin.loadingUsers")}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.username")}</TableHead>
|
||||
<TableHead>{t("admin.authType")}</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
{user.username}
|
||||
{user.isAdmin && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{t("admin.adminBadge")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{getAuthTypeDisplay(user)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEditUser(user)}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
title={t("admin.manageUser")}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{user.isOidc && !user.passwordHash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onLinkOIDCUser({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
|
||||
title={t("admin.linkOidcTitle")}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{user.isOidc && user.passwordHash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onUnlinkOIDC(user.id, user.username)}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
title={t("admin.unlinkOidcTitle")}
|
||||
>
|
||||
<Unlink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUserQuick(user.username)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.isAdmin}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import type { SSHHost } from "@/types";
|
||||
import { Dashboard } from "@/dashboard/Dashboard.tsx";
|
||||
import { Toaster } from "@/components/sonner.tsx";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface FullScreenAppWrapperProps {
|
||||
hostId?: string;
|
||||
@@ -17,6 +19,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
hostId,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [hostConfig, setHostConfig] = useState<SSHHost | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
@@ -84,13 +87,16 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div
|
||||
className="w-full h-screen overflow-hidden flex items-center justify-center"
|
||||
style={{ backgroundColor: "#18181b" }}
|
||||
className="w-full h-screen overflow-hidden flex flex-col items-center justify-center gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
<RefreshCw
|
||||
className="size-8 animate-spin"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<p className="text-sm" style={{ color: "var(--foreground-secondary)" }}>
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,7 +108,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
<CommandHistoryProvider>
|
||||
<div
|
||||
className="w-full h-screen overflow-hidden flex items-center justify-center"
|
||||
style={{ backgroundColor: "#18181b" }}
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<Dashboard
|
||||
isAuthenticated={false}
|
||||
@@ -131,7 +137,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
|
||||
<CommandHistoryProvider>
|
||||
<div
|
||||
className="w-full h-screen overflow-hidden"
|
||||
style={{ backgroundColor: "#18181b" }}
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
{children(hostConfig, loading)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DockerManager } from "@/features/docker/DockerManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
|
||||
@@ -7,6 +8,7 @@ interface DockerAppProps {
|
||||
}
|
||||
|
||||
const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
@@ -15,7 +17,9 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.loadingHost")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -25,7 +29,7 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileManager } from "@/features/file-manager/FileManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
|
||||
@@ -7,6 +8,7 @@ interface FileManagerAppProps {
|
||||
}
|
||||
|
||||
const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
@@ -15,7 +17,9 @@ const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.loadingHost")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -25,7 +29,7 @@ const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { getGuacamoleTokenFromHost } from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
interface GuacamoleAppProps {
|
||||
@@ -18,20 +20,26 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
|
||||
<RefreshCw className="size-8 animate-spin" />
|
||||
<span className="text-sm font-semibold uppercase tracking-widest">
|
||||
{t("common.loading")}
|
||||
</span>
|
||||
<div className="relative w-full h-full">
|
||||
<SimpleLoader visible={true} message={t("common.loading")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
|
||||
<AlertCircle className="size-8 text-destructive" />
|
||||
<span className="text-sm font-semibold text-destructive">
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.hostNotFound")}
|
||||
</span>
|
||||
</div>
|
||||
@@ -61,31 +69,63 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setToken(null);
|
||||
setError(null);
|
||||
getGuacamoleTokenFromHost(hostId)
|
||||
.then((result) => setToken(result.token))
|
||||
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
|
||||
}, [hostId]);
|
||||
}, [hostId, retryCount]);
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setConnectionError(null);
|
||||
setError(null);
|
||||
setToken(null);
|
||||
setRetryCount((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
|
||||
<AlertCircle className="size-8 text-destructive" />
|
||||
<span className="text-sm font-semibold text-destructive">{error}</span>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full gap-4"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.connectionFailed")}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs max-w-xs text-center"
|
||||
style={{ color: "var(--foreground-secondary)" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleReconnect}>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
{t("guacamole.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
|
||||
<RefreshCw className="size-8 animate-spin" />
|
||||
<span className="text-sm font-semibold uppercase tracking-widest">
|
||||
{t("guacamole.connecting", {
|
||||
<div className="relative w-full h-full">
|
||||
<SimpleLoader
|
||||
visible={true}
|
||||
message={t("guacamole.connecting", {
|
||||
type: (hostConfig.connectionType || "remote").toUpperCase(),
|
||||
})}
|
||||
</span>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -94,9 +134,37 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{connectionError && (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-4 z-50"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<AlertCircle
|
||||
className="size-10"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
/>
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.connectionFailed")}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs max-w-xs text-center"
|
||||
style={{ color: "var(--foreground-secondary)" }}
|
||||
>
|
||||
{connectionError}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleReconnect}>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
{t("guacamole.reconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<GuacamoleDisplay
|
||||
connectionConfig={{ token, protocol, type: protocol }}
|
||||
isVisible={true}
|
||||
onError={(err) => setConnectionError(err)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@ import Guacamole from "guacamole-common-js";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getGuacamoleToken, isElectron, isEmbeddedMode } from "@/main-axios.ts";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { WifiOff, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
|
||||
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
|
||||
|
||||
@@ -65,6 +67,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
typeof document === "undefined" ? true : document.hasFocus(),
|
||||
);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
disconnect: () => {
|
||||
@@ -267,6 +270,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
if (isConnectingRef.current) return;
|
||||
isConnectingRef.current = true;
|
||||
setIsReady(false);
|
||||
setConnectionError(null);
|
||||
|
||||
let containerWidth = containerRef.current?.clientWidth || 0;
|
||||
let containerHeight = containerRef.current?.clientHeight || 0;
|
||||
@@ -351,6 +355,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
case 2:
|
||||
break;
|
||||
case 3:
|
||||
isConnectingRef.current = false;
|
||||
setIsReady(true);
|
||||
onConnect?.();
|
||||
break;
|
||||
@@ -366,8 +371,10 @@ export const GuacamoleDisplay = forwardRef<
|
||||
};
|
||||
|
||||
client.onerror = (error: Guacamole.Status) => {
|
||||
const errorMessage = error.message || "Connection error";
|
||||
const errorMessage = error.message || t("guacamole.connectionError");
|
||||
setIsReady(false);
|
||||
setConnectionError(errorMessage);
|
||||
isConnectingRef.current = false;
|
||||
onError?.(errorMessage);
|
||||
};
|
||||
|
||||
@@ -530,6 +537,18 @@ export const GuacamoleDisplay = forwardRef<
|
||||
};
|
||||
}, [isReady, syncClipboard]);
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setConnectionError(null);
|
||||
hasInitiatedRef.current = false;
|
||||
isConnectingRef.current = false;
|
||||
if (clientRef.current) {
|
||||
clientRef.current.disconnect();
|
||||
clientRef.current = null;
|
||||
}
|
||||
displayElementRef.current = null;
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
const connectingMessage = t("guacamole.connecting", {
|
||||
type: (
|
||||
connectionConfig.protocol ||
|
||||
@@ -553,7 +572,32 @@ export const GuacamoleDisplay = forwardRef<
|
||||
}}
|
||||
/>
|
||||
|
||||
<SimpleLoader visible={!isReady} message={connectingMessage} />
|
||||
{connectionError ? (
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-center gap-4 z-[100]"
|
||||
style={{ backgroundColor: "var(--bg-base)" }}
|
||||
>
|
||||
<WifiOff className="size-10" style={{ color: "var(--foreground)" }} />
|
||||
<p
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{t("guacamole.connectionFailed")}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs max-w-xs text-center"
|
||||
style={{ color: "var(--foreground-secondary)" }}
|
||||
>
|
||||
{connectionError}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleReconnect}>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
{t("guacamole.reconnect")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<SimpleLoader visible={!isReady} message={connectingMessage} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ServerStats } from "@/features/server-stats/ServerStats.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
|
||||
@@ -7,6 +8,7 @@ interface ServerStatsAppProps {
|
||||
}
|
||||
|
||||
const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
@@ -15,7 +17,9 @@ const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.loadingHost")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -25,7 +29,7 @@ const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -688,9 +688,28 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
},
|
||||
fit: () => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
if (!fitAddonRef.current || !terminal || isFittingRef.current) return;
|
||||
isFittingRef.current = true;
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
if (terminal.cols > 0 && terminal.rows > 0) {
|
||||
const lastSize = lastFittedSizeRef.current;
|
||||
if (
|
||||
!lastSize ||
|
||||
lastSize.cols !== terminal.cols ||
|
||||
lastSize.rows !== terminal.rows
|
||||
) {
|
||||
scheduleNotify(terminal.cols, terminal.rows);
|
||||
lastFittedSizeRef.current = {
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
};
|
||||
}
|
||||
}
|
||||
setIsFitted(true);
|
||||
} finally {
|
||||
isFittingRef.current = false;
|
||||
}
|
||||
},
|
||||
sendInput: (data: string) => {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
@@ -2448,7 +2467,13 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}, [terminal, hostConfig.id, isVisible, isConnected, isConnecting]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !fitAddonRef.current || !isVisible) return;
|
||||
if (!terminal || !fitAddonRef.current) return;
|
||||
|
||||
if (!isVisible) {
|
||||
lastFittedSizeRef.current = null;
|
||||
lastSentSizeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const fitTimeoutId = setTimeout(() => {
|
||||
if (!isFittingRef.current && terminal.cols > 0 && terminal.rows > 0) {
|
||||
@@ -2457,7 +2482,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
requestAnimationFrame(() => terminal.focus());
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(fitTimeoutId);
|
||||
}, [terminal, isVisible, splitScreen, isConnecting]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal } from "@/features/terminal/Terminal.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
|
||||
@@ -7,6 +8,7 @@ interface TerminalAppProps {
|
||||
}
|
||||
|
||||
const TerminalApp: React.FC<TerminalAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
@@ -15,7 +17,9 @@ const TerminalApp: React.FC<TerminalAppProps> = ({ hostId }) => {
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("hosts.loadingHost")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -25,7 +29,7 @@ const TerminalApp: React.FC<TerminalAppProps> = ({ hostId }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/lib/terminal-themes.ts";
|
||||
import { useTheme } from "@/components/theme-provider.tsx";
|
||||
|
||||
interface TerminalPreviewProps {
|
||||
theme: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
cursorStyle?: "block" | "underline" | "bar";
|
||||
cursorBlink?: boolean;
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
}
|
||||
|
||||
export function TerminalPreview({
|
||||
theme = "termix",
|
||||
fontSize = 14,
|
||||
fontFamily = "Caskaydia Cove Nerd Font Mono",
|
||||
cursorStyle = "bar",
|
||||
cursorBlink = true,
|
||||
letterSpacing = 0,
|
||||
lineHeight = 1.2,
|
||||
}: TerminalPreviewProps) {
|
||||
const { theme: appTheme } = useTheme();
|
||||
|
||||
const resolvedTheme =
|
||||
theme === "termix"
|
||||
? appTheme === "dark" ||
|
||||
(appTheme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
? "termixDark"
|
||||
: "termixLight"
|
||||
: theme;
|
||||
|
||||
return (
|
||||
<div className="border border-input rounded-md overflow-hidden">
|
||||
<div
|
||||
className="p-4 font-mono text-sm"
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily:
|
||||
TERMINAL_FONTS.find((f) => f.value === fontFamily)?.fallback ||
|
||||
TERMINAL_FONTS[0].fallback,
|
||||
letterSpacing: `${letterSpacing}px`,
|
||||
lineHeight,
|
||||
background:
|
||||
TERMINAL_THEMES[resolvedTheme]?.colors.background ||
|
||||
"var(--bg-base)",
|
||||
color:
|
||||
TERMINAL_THEMES[resolvedTheme]?.colors.foreground ||
|
||||
"var(--foreground)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
|
||||
user@termix
|
||||
</span>
|
||||
<span>:</span>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
|
||||
~
|
||||
</span>
|
||||
<span>$ ls -la</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
|
||||
drwxr-xr-x
|
||||
</span>
|
||||
<span> 5 user </span>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.cyan }}>
|
||||
docs
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
|
||||
-rwxr-xr-x
|
||||
</span>
|
||||
<span> 1 user </span>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
|
||||
script.sh
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>-rw-r--r--</span>
|
||||
<span> 1 user </span>
|
||||
<span>README.md</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
|
||||
user@termix
|
||||
</span>
|
||||
<span>:</span>
|
||||
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
|
||||
~
|
||||
</span>
|
||||
<span>$ </span>
|
||||
<span
|
||||
className="inline-block"
|
||||
style={{
|
||||
width: cursorStyle === "block" ? "0.6em" : "0.1em",
|
||||
height:
|
||||
cursorStyle === "underline"
|
||||
? "0.15em"
|
||||
: cursorStyle === "bar"
|
||||
? `${fontSize}px`
|
||||
: `${fontSize}px`,
|
||||
background:
|
||||
TERMINAL_THEMES[resolvedTheme]?.colors.cursor || "#f7f7f7",
|
||||
animation: cursorBlink ? "blink 1s step-end infinite" : "none",
|
||||
verticalAlign:
|
||||
cursorStyle === "underline" ? "bottom" : "text-bottom",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { TunnelViewer } from "@/features/tunnel/TunnelViewer.tsx";
|
||||
import {
|
||||
getSSHHosts,
|
||||
subscribeTunnelStatuses,
|
||||
connectTunnel,
|
||||
disconnectTunnel,
|
||||
cancelTunnel,
|
||||
logActivity,
|
||||
} from "@/main-axios.ts";
|
||||
import type {
|
||||
SSHHost,
|
||||
TunnelConnection,
|
||||
TunnelStatus,
|
||||
SSHTunnelProps,
|
||||
} from "@/types/index";
|
||||
|
||||
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
||||
const [tunnelStatuses, setTunnelStatuses] = useState<
|
||||
Record<string, TunnelStatus>
|
||||
>({});
|
||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
|
||||
const haveTunnelConnectionsChanged = (
|
||||
a: TunnelConnection[] = [],
|
||||
b: TunnelConnection[] = [],
|
||||
): boolean => {
|
||||
if (a.length !== b.length) return true;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (
|
||||
x.sourcePort !== y.sourcePort ||
|
||||
x.endpointPort !== y.endpointPort ||
|
||||
x.endpointHost !== y.endpointHost ||
|
||||
x.maxRetries !== y.maxRetries ||
|
||||
x.retryInterval !== y.retryInterval ||
|
||||
x.autoStart !== y.autoStart
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const fetchHosts = useCallback(async () => {
|
||||
const hostsData = await getSSHHosts();
|
||||
setAllHosts(hostsData);
|
||||
const nextVisible = filterHostKey
|
||||
? hostsData.filter((h) => {
|
||||
const key =
|
||||
h.name && h.name.trim() !== "" ? h.name : `${h.username}@${h.ip}`;
|
||||
return key === filterHostKey;
|
||||
})
|
||||
: hostsData;
|
||||
|
||||
const prev = prevVisibleHostRef.current;
|
||||
const curr = nextVisible[0] ?? null;
|
||||
let changed = false;
|
||||
if (!prev && curr) changed = true;
|
||||
else if (prev && !curr) changed = true;
|
||||
else if (prev && curr) {
|
||||
if (
|
||||
prev.id !== curr.id ||
|
||||
prev.name !== curr.name ||
|
||||
prev.ip !== curr.ip ||
|
||||
prev.port !== curr.port ||
|
||||
prev.username !== curr.username ||
|
||||
haveTunnelConnectionsChanged(
|
||||
prev.tunnelConnections,
|
||||
curr.tunnelConnections,
|
||||
)
|
||||
) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setVisibleHosts(nextVisible);
|
||||
prevVisibleHostRef.current = curr;
|
||||
}
|
||||
}, [filterHostKey]);
|
||||
|
||||
const logTunnelActivity = async (host: SSHHost) => {
|
||||
if (!host?.id || activityLoggedRef.current || activityLoggingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityLoggingRef.current = true;
|
||||
activityLoggedRef.current = true;
|
||||
|
||||
try {
|
||||
const hostName = host.name || `${host.username}@${host.ip}`;
|
||||
await logActivity("tunnel", host.id, hostName);
|
||||
} catch (err) {
|
||||
console.warn("Failed to log tunnel activity:", err);
|
||||
activityLoggedRef.current = false;
|
||||
} finally {
|
||||
activityLoggingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 5000);
|
||||
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
window.addEventListener(
|
||||
"ssh-hosts:changed",
|
||||
handleHostsChanged as EventListener,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener(
|
||||
"ssh-hosts:changed",
|
||||
handleHostsChanged as EventListener,
|
||||
);
|
||||
};
|
||||
}, [fetchHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeTunnelStatuses(setTunnelStatuses, () => {
|
||||
// The view remains usable if the stream reconnects or is unavailable.
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleHosts.length > 0 && visibleHosts[0]) {
|
||||
logTunnelActivity(visibleHosts[0]);
|
||||
}
|
||||
}, [visibleHosts.length > 0 ? visibleHosts[0]?.id : null]);
|
||||
|
||||
const handleTunnelAction = async (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||
|
||||
setTunnelActions((prev) => ({ ...prev, [tunnelName]: true }));
|
||||
|
||||
try {
|
||||
if (action === "connect") {
|
||||
const endpointHost = allHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
|
||||
const tunnelConfig = {
|
||||
name: tunnelName,
|
||||
scope: tunnel.scope || "s2s",
|
||||
mode: tunnel.mode || tunnel.tunnelType || "remote",
|
||||
bindHost: tunnel.bindHost,
|
||||
targetHost: tunnel.targetHost,
|
||||
tunnelType:
|
||||
tunnel.tunnelType ||
|
||||
(tunnel.mode === "local" || tunnel.mode === "remote"
|
||||
? tunnel.mode
|
||||
: "remote"),
|
||||
sourceHostId: host.id,
|
||||
tunnelIndex: tunnelIndex,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
sourcePassword:
|
||||
host.authType === "password" ? host.password : undefined,
|
||||
sourceAuthMethod: host.authType,
|
||||
sourceSSHKey: host.authType === "key" ? host.key : undefined,
|
||||
sourceKeyPassword:
|
||||
host.authType === "key" ? host.keyPassword : undefined,
|
||||
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
|
||||
sourceCredentialId: host.credentialId,
|
||||
sourceUserId: host.userId,
|
||||
endpointHost: tunnel.endpointHost,
|
||||
endpointIP: endpointHost?.ip,
|
||||
endpointSSHPort: endpointHost?.port,
|
||||
endpointUsername: endpointHost?.username,
|
||||
endpointPassword:
|
||||
endpointHost?.authType === "password"
|
||||
? endpointHost.password
|
||||
: undefined,
|
||||
endpointAuthMethod: endpointHost?.authType,
|
||||
endpointSSHKey:
|
||||
endpointHost?.authType === "key" ? endpointHost.key : undefined,
|
||||
endpointKeyPassword:
|
||||
endpointHost?.authType === "key"
|
||||
? endpointHost.keyPassword
|
||||
: undefined,
|
||||
endpointKeyType:
|
||||
endpointHost?.authType === "key" ? endpointHost.keyType : undefined,
|
||||
endpointCredentialId: endpointHost?.credentialId,
|
||||
endpointUserId: endpointHost?.userId,
|
||||
sourcePort: tunnel.sourcePort,
|
||||
endpointPort: tunnel.endpointPort,
|
||||
maxRetries: tunnel.maxRetries,
|
||||
retryInterval: tunnel.retryInterval * 1000,
|
||||
autoStart: tunnel.autoStart,
|
||||
isPinned: host.pin,
|
||||
useSocks5: host.useSocks5,
|
||||
socks5Host: host.socks5Host,
|
||||
socks5Port: host.socks5Port,
|
||||
socks5Username: host.socks5Username,
|
||||
socks5Password: host.socks5Password,
|
||||
socks5ProxyChain: host.socks5ProxyChain,
|
||||
};
|
||||
await connectTunnel(tunnelConfig);
|
||||
} else if (action === "disconnect") {
|
||||
await disconnectTunnel(tunnelName);
|
||||
} else if (action === "cancel") {
|
||||
await cancelTunnel(tunnelName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Tunnel action failed:", {
|
||||
action,
|
||||
tunnelName,
|
||||
hostId: host.id,
|
||||
tunnelIndex,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
fullError: error,
|
||||
});
|
||||
} finally {
|
||||
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TunnelViewer
|
||||
hosts={visibleHosts}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={handleTunnelAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,55 @@
|
||||
import React from "react";
|
||||
import { TunnelManager } from "@/features/tunnel/TunnelManager.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { TunnelTab } from "@/features/tunnel/TunnelTab.tsx";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
interface TunnelAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
function sshHostToMinimalHost(h: SSHHost): Host {
|
||||
return {
|
||||
id: String(h.id),
|
||||
name: h.name,
|
||||
ip: h.ip,
|
||||
port: h.port,
|
||||
username: h.username,
|
||||
folder: h.folder ?? "",
|
||||
online: false,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: "",
|
||||
tags: h.tags ?? [],
|
||||
pin: h.pin ?? false,
|
||||
authType: h.authType,
|
||||
enableTerminal: h.enableTerminal ?? false,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
enableFileManager: h.enableFileManager ?? false,
|
||||
enableDocker: h.enableDocker ?? false,
|
||||
enableSsh: h.enableSsh ?? true,
|
||||
enableRdp: h.enableRdp ?? false,
|
||||
enableVnc: h.enableVnc ?? false,
|
||||
enableTelnet: h.enableTelnet ?? false,
|
||||
sshPort: h.sshPort ?? h.port,
|
||||
rdpPort: h.rdpPort ?? 3389,
|
||||
vncPort: h.vncPort ?? 5900,
|
||||
telnetPort: h.telnetPort ?? 23,
|
||||
serverTunnels: [],
|
||||
quickActions: [],
|
||||
};
|
||||
}
|
||||
|
||||
const TunnelApp: React.FC<TunnelAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, loading) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading host...</p>
|
||||
</div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,20 +57,15 @@ const TunnelApp: React.FC<TunnelAppProps> = ({ hostId }) => {
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">Host not found</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{t("hosts.hostNotFound")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TunnelManager
|
||||
hostConfig={hostConfig}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
<TunnelTab
|
||||
label={hostConfig.name}
|
||||
host={sshHostToMinimalHost(hostConfig)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -88,28 +88,28 @@ export function TunnelInlineControls({
|
||||
|
||||
const statusClass =
|
||||
kind === "connected"
|
||||
? "text-green-600 dark:text-green-400 bg-green-500/10 border-green-500/20"
|
||||
? "text-accent-brand border-accent-brand/40 bg-accent-brand/10"
|
||||
: kind === "connecting"
|
||||
? "text-blue-600 dark:text-blue-400 bg-blue-500/10 border-blue-500/20"
|
||||
? "text-blue-400 border-blue-400/40 bg-blue-400/10"
|
||||
: kind === "error"
|
||||
? "text-red-600 dark:text-red-400 bg-red-500/10 border-red-500/20"
|
||||
: "text-muted-foreground bg-muted/30 border-border";
|
||||
? "text-destructive border-destructive/40 bg-destructive/10"
|
||||
: "text-muted-foreground border-border bg-muted/30";
|
||||
|
||||
const statusIcon =
|
||||
kind === "connected" ? (
|
||||
<Wifi className="h-3 w-3" />
|
||||
<Wifi className="size-3" />
|
||||
) : kind === "connecting" ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : kind === "error" ? (
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<AlertCircle className="size-3" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3" />
|
||||
<WifiOff className="size-3" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<span
|
||||
className={`inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-medium ${statusClass}`}
|
||||
className={`inline-flex h-8 items-center gap-1.5 border px-2 text-[10px] font-bold uppercase tracking-wide ${statusClass}`}
|
||||
title={title}
|
||||
>
|
||||
{statusIcon}
|
||||
@@ -123,7 +123,7 @@ export function TunnelInlineControls({
|
||||
disabled
|
||||
className="h-8 px-3 text-xs text-muted-foreground border-border"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
<Loader2 className="size-3 mr-1 animate-spin" />
|
||||
{isDisconnected ? t("tunnels.start") : t("tunnels.stop")}
|
||||
</Button>
|
||||
) : isDisconnected ? (
|
||||
@@ -134,9 +134,9 @@ export function TunnelInlineControls({
|
||||
onClick={onStart}
|
||||
disabled={startDisabled}
|
||||
title={startDisabled ? startDisabledReason : undefined}
|
||||
className="h-8 px-3 text-xs text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50"
|
||||
className="h-8 px-3 text-xs text-accent-brand border-accent-brand/40 hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
<Play className="size-3 mr-1" />
|
||||
{t("tunnels.start")}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -145,9 +145,9 @@ export function TunnelInlineControls({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onStop}
|
||||
className="h-8 px-3 text-xs text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50"
|
||||
className="h-8 px-3 text-xs text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
<Square className="size-3 mr-1" />
|
||||
{t("tunnels.stop")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Tunnel } from "@/features/tunnel/Tunnel.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSSHHosts } from "@/main-axios.ts";
|
||||
import { useTabsSafe } from "@/shell/TabContext.tsx";
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
username: string;
|
||||
folder?: string;
|
||||
enableFileManager?: boolean;
|
||||
tunnelConnections?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TunnelManagerProps {
|
||||
hostConfig?: HostConfig;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function TunnelManager({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: TunnelManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { tabs, addTab, setCurrentTab, updateTab } = useTabsSafe();
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const isElectron =
|
||||
typeof window !== "undefined" && window.electronAPI?.isElectron === true;
|
||||
|
||||
const openC2SPresets = React.useCallback(() => {
|
||||
const profileTab = tabs.find((tab) => tab.type === "user_profile");
|
||||
if (profileTab) {
|
||||
updateTab(profileTab.id, {
|
||||
initialTab: "c2s-tunnels",
|
||||
_updateTimestamp: Date.now(),
|
||||
});
|
||||
setCurrentTab(profileTab.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = addTab({
|
||||
type: "user_profile",
|
||||
title: t("profile.title"),
|
||||
initialTab: "c2s-tunnels",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}, [addTab, setCurrentTab, t, tabs, updateTab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-foreground overflow-hidden bg-transparent"
|
||||
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
{isElectron && (
|
||||
<Button size="sm" variant="outline" onClick={openC2SPresets}>
|
||||
{t("tunnels.manageClientTunnels")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0 p-1">
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
currentHostConfig.tunnelConnections.length > 0 ? (
|
||||
<div className="rounded-lg h-full overflow-hidden flex flex-col min-h-0">
|
||||
<Tunnel
|
||||
filterHostKey={
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name
|
||||
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-foreground-subtle text-lg">
|
||||
{t("tunnels.noTunnelsConfigured")}
|
||||
</p>
|
||||
<p className="text-foreground-subtle text-sm mt-2">
|
||||
{t("tunnels.configureTunnelsInHostSettings")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,26 +46,32 @@ export function TunnelModeSelector({
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="grid gap-2 lg:grid-cols-3">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
<button
|
||||
key={option.value}
|
||||
className="flex items-start gap-3 rounded-md border bg-card p-3 cursor-pointer"
|
||||
type="button"
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`flex items-start gap-2.5 border p-3 text-left transition-colors ${
|
||||
mode === option.value
|
||||
? "border-accent-brand bg-accent-brand/5 text-foreground"
|
||||
: "border-border bg-muted/20 text-muted-foreground hover:border-border/80 hover:bg-muted/30"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
value={option.value}
|
||||
checked={mode === option.value}
|
||||
onChange={() => onChange(option.value)}
|
||||
className="mt-0.5 w-4 h-4 text-primary border-input focus:ring-ring"
|
||||
<div
|
||||
className={`mt-0.5 size-3.5 shrink-0 rounded-full border-2 ${
|
||||
mode === option.value
|
||||
? "border-accent-brand bg-accent-brand"
|
||||
: "border-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{option.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-semibold">{option.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight">
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,532 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Loader2,
|
||||
Pin,
|
||||
Network,
|
||||
Tag,
|
||||
Play,
|
||||
Square,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/badge.tsx";
|
||||
import type { TunnelStatus, SSHTunnelObjectProps } from "@/types/index";
|
||||
|
||||
export function TunnelObject({
|
||||
host,
|
||||
tunnelIndex,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction,
|
||||
compact = false,
|
||||
bare = false,
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getTunnelStatus = (idx: number): TunnelStatus | undefined => {
|
||||
const tunnel = host.tunnelConnections[idx];
|
||||
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||
return tunnelStatuses[tunnelName];
|
||||
};
|
||||
|
||||
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
|
||||
if (!status)
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: t("tunnels.unknown"),
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted/50",
|
||||
borderColor: "border-border",
|
||||
};
|
||||
|
||||
const statusValue = status.status || "DISCONNECTED";
|
||||
|
||||
switch (statusValue.toUpperCase()) {
|
||||
case "CONNECTED":
|
||||
return {
|
||||
icon: <Wifi className="h-4 w-4" />,
|
||||
text: t("tunnels.connected"),
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
bgColor: "bg-green-500/10 dark:bg-green-400/10",
|
||||
borderColor: "border-green-500/20 dark:border-green-400/20",
|
||||
};
|
||||
case "CONNECTING":
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
text: t("tunnels.connecting"),
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
|
||||
borderColor: "border-blue-500/20 dark:border-blue-400/20",
|
||||
};
|
||||
case "DISCONNECTING":
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
text: t("tunnels.disconnecting"),
|
||||
color: "text-orange-600 dark:text-orange-400",
|
||||
bgColor: "bg-orange-500/10 dark:bg-orange-400/10",
|
||||
borderColor: "border-orange-500/20 dark:border-orange-400/20",
|
||||
};
|
||||
case "DISCONNECTED":
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: t("tunnels.disconnected"),
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted/30",
|
||||
borderColor: "border-border",
|
||||
};
|
||||
case "WAITING":
|
||||
return {
|
||||
icon: <Clock className="h-4 w-4" />,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
|
||||
borderColor: "border-blue-500/20 dark:border-blue-400/20",
|
||||
};
|
||||
case "ERROR":
|
||||
case "FAILED":
|
||||
return {
|
||||
icon: <AlertCircle className="h-4 w-4" />,
|
||||
text: status.reason || t("tunnels.error"),
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-500/10 dark:bg-red-400/10",
|
||||
borderColor: "border-red-500/20 dark:border-red-400/20",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: t("tunnels.unknown"),
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted/30",
|
||||
borderColor: "border-border",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (bare) {
|
||||
return (
|
||||
<div className="w-full min-w-0">
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{(tunnelIndex !== undefined
|
||||
? [tunnelIndex]
|
||||
: host.tunnelConnections.map((_, idx) => idx)
|
||||
).map((idx) => {
|
||||
const tunnel = host.tunnelConnections[idx];
|
||||
const status = getTunnelStatus(idx);
|
||||
const statusDisplay = getTunnelStatusDisplay(status);
|
||||
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||
const isActionLoading = tunnelActions[tunnelName];
|
||||
const statusValue =
|
||||
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||
const isConnected = statusValue === "CONNECTED";
|
||||
const isConnecting = statusValue === "CONNECTING";
|
||||
const isDisconnecting = statusValue === "DISCONNECTING";
|
||||
const isRetrying = statusValue === "RETRYING";
|
||||
const isWaiting = statusValue === "WAITING";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span
|
||||
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
|
||||
>
|
||||
{statusDisplay.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">
|
||||
{t("tunnels.port")} {tunnel.sourcePort} →{" "}
|
||||
{tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${statusDisplay.color} font-medium`}
|
||||
>
|
||||
{statusDisplay.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
|
||||
{!isActionLoading ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("disconnect", host, idx)
|
||||
}
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.stop")}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("cancel", host, idx)
|
||||
}
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.cancel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("connect", host, idx)
|
||||
}
|
||||
disabled={isConnecting || isDisconnecting}
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.start")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{isConnected
|
||||
? t("tunnels.disconnecting")
|
||||
: isRetrying || isWaiting
|
||||
? t("tunnels.canceling")
|
||||
: t("tunnels.connecting")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(statusValue === "ERROR" || statusValue === "FAILED") &&
|
||||
status?.reason && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{t("tunnels.error")}:
|
||||
</div>
|
||||
{status.reason}
|
||||
{status.reason &&
|
||||
status.reason.includes("Max retries exhausted") && (
|
||||
<>
|
||||
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
{t("tunnels.checkDockerLogs")}{" "}
|
||||
<a
|
||||
href="https://discord.com/invite/jVQGdvHDrf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{t("tunnels.discord")}
|
||||
</a>{" "}
|
||||
{t("tunnels.orCreate")}{" "}
|
||||
<a
|
||||
href="https://github.com/Termix-SSH/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{t("tunnels.githubIssue")}
|
||||
</a>{" "}
|
||||
{t("tunnels.forHelp")}.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(statusValue === "RETRYING" ||
|
||||
statusValue === "WAITING") &&
|
||||
status?.retryCount &&
|
||||
status?.maxRetries && (
|
||||
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === "WAITING"
|
||||
? t("tunnels.waitingForRetry")
|
||||
: t("tunnels.retryingConnection")}
|
||||
</div>
|
||||
<div>
|
||||
{t("tunnels.attempt", {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries,
|
||||
})}
|
||||
{status.nextRetryIn && (
|
||||
<span>
|
||||
{" "}
|
||||
•{" "}
|
||||
{t("tunnels.nextRetryIn", {
|
||||
seconds: status.nextRetryIn,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||
<div className="p-4">
|
||||
{!compact && (
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{host.pin && (
|
||||
<Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-card-foreground truncate">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port} • {host.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{host.tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Tag className="h-2 w-2 mr-0.5" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{host.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
+{host.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && <Separator className="mb-3" />}
|
||||
|
||||
<div className="space-y-3">
|
||||
{!compact && (
|
||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||
<Network className="h-4 w-4" />
|
||||
{t("tunnels.tunnelConnections")} (
|
||||
{tunnelIndex !== undefined ? 1 : host.tunnelConnections.length})
|
||||
</h4>
|
||||
)}
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{(tunnelIndex !== undefined
|
||||
? [tunnelIndex]
|
||||
: host.tunnelConnections.map((_, idx) => idx)
|
||||
).map((idx) => {
|
||||
const tunnel = host.tunnelConnections[idx];
|
||||
const status = getTunnelStatus(idx);
|
||||
const statusDisplay = getTunnelStatusDisplay(status);
|
||||
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
|
||||
const isActionLoading = tunnelActions[tunnelName];
|
||||
const statusValue =
|
||||
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||
const isConnected = statusValue === "CONNECTED";
|
||||
const isConnecting = statusValue === "CONNECTING";
|
||||
const isDisconnecting = statusValue === "DISCONNECTING";
|
||||
const isRetrying = statusValue === "RETRYING";
|
||||
const isWaiting = statusValue === "WAITING";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span
|
||||
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
|
||||
>
|
||||
{statusDisplay.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">
|
||||
{t("tunnels.port")} {tunnel.sourcePort} →{" "}
|
||||
{tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${statusDisplay.color} font-medium`}
|
||||
>
|
||||
{statusDisplay.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!isActionLoading && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("disconnect", host, idx)
|
||||
}
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.stop")}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("cancel", host, idx)
|
||||
}
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.cancel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("connect", host, idx)
|
||||
}
|
||||
disabled={isConnecting || isDisconnecting}
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.start")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isActionLoading && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{isConnected
|
||||
? t("tunnels.disconnecting")
|
||||
: isRetrying || isWaiting
|
||||
? t("tunnels.canceling")
|
||||
: t("tunnels.connecting")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(statusValue === "ERROR" || statusValue === "FAILED") &&
|
||||
status?.reason && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{t("tunnels.error")}:
|
||||
</div>
|
||||
{status.reason}
|
||||
{status.reason &&
|
||||
status.reason.includes("Max retries exhausted") && (
|
||||
<>
|
||||
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
{t("tunnels.checkDockerLogs")}{" "}
|
||||
<a
|
||||
href="https://discord.com/invite/jVQGdvHDrf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{t("tunnels.discord")}
|
||||
</a>{" "}
|
||||
{t("tunnels.orCreate")}{" "}
|
||||
<a
|
||||
href="https://github.com/Termix-SSH/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
{t("tunnels.githubIssue")}
|
||||
</a>{" "}
|
||||
{t("tunnels.forHelp")}.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(statusValue === "RETRYING" ||
|
||||
statusValue === "WAITING") &&
|
||||
status?.retryCount &&
|
||||
status?.maxRetries && (
|
||||
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === "WAITING"
|
||||
? t("tunnels.waitingForRetry")
|
||||
: t("tunnels.retryingConnection")}
|
||||
</div>
|
||||
<div>
|
||||
{t("tunnels.attempt", {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries,
|
||||
})}
|
||||
{status.nextRetryIn && (
|
||||
<span>
|
||||
{" "}
|
||||
•{" "}
|
||||
{t("tunnels.nextRetryIn", {
|
||||
seconds: status.nextRetryIn,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from "react";
|
||||
import { TunnelObject } from "./TunnelObject.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SSHHost, TunnelStatus } from "@/types/index";
|
||||
|
||||
interface SSHTunnelViewerProps {
|
||||
hosts: SSHHost[];
|
||||
tunnelStatuses: Record<string, TunnelStatus>;
|
||||
tunnelActions: Record<string, boolean>;
|
||||
onTunnelAction: (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export function TunnelViewer({
|
||||
hosts = [],
|
||||
tunnelStatuses = {},
|
||||
tunnelActions = {},
|
||||
onTunnelAction,
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const activeHost: SSHHost | undefined =
|
||||
Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
||||
|
||||
if (
|
||||
!activeHost ||
|
||||
!activeHost.tunnelConnections ||
|
||||
activeHost.tunnelConnections.length === 0
|
||||
) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t("tunnels.noSshTunnels")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{t("tunnels.createFirstTunnelMessage")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
|
||||
<div className="min-h-0 flex-1 overflow-auto thin-scrollbar pr-1">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{activeHost.tunnelConnections.map((t, idx) => (
|
||||
<TunnelObject
|
||||
key={`tunnel-${activeHost.id}-${idx}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
||||
host={activeHost}
|
||||
tunnelIndex={idx}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={onTunnelAction}
|
||||
compact
|
||||
bare
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+11
-1
@@ -113,7 +113,7 @@
|
||||
--accent: oklch(0.255 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10.8%);
|
||||
--border: oklch(1 0 0 / 12%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
@@ -478,3 +478,13 @@ html.fs-xl {
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
+764
-884
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Kbd } from "@/components/kbd";
|
||||
import {
|
||||
@@ -91,6 +92,7 @@ export function CommandPalette({
|
||||
hosts,
|
||||
onOpenTab,
|
||||
}: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
|
||||
@@ -169,7 +171,7 @@ export function CommandPalette({
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search hosts, commands, or settings..."
|
||||
placeholder={t("commandPalette.searchPlaceholder")}
|
||||
className="flex-1 h-12 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
@@ -180,7 +182,10 @@ export function CommandPalette({
|
||||
</div>
|
||||
|
||||
<CommandList className="max-h-[60vh] thin-scrollbar">
|
||||
<CommandGroup heading="Quick Actions" className="px-2">
|
||||
<CommandGroup
|
||||
heading={t("commandPalette.quickActions")}
|
||||
className="px-2"
|
||||
>
|
||||
<CommandItem
|
||||
onSelect={() => handleAction(() => onOpenTab("host-manager"))}
|
||||
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
@@ -189,9 +194,11 @@ export function CommandPalette({
|
||||
<LayoutDashboard className="size-4 text-accent-brand" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="text-sm font-semibold">Host Manager</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{t("commandPalette.hostManager")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Manage, add, or edit hosts
|
||||
{t("commandPalette.hostManagerDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
@@ -212,9 +219,11 @@ export function CommandPalette({
|
||||
<Plus className="size-4 text-accent-brand" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="text-sm font-semibold">Add New Host</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{t("commandPalette.addNewHost")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Register a new host
|
||||
{t("commandPalette.addNewHostDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
@@ -227,9 +236,11 @@ export function CommandPalette({
|
||||
<Settings className="size-4 text-accent-brand" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="text-sm font-semibold">Admin Settings</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{t("commandPalette.adminSettings")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Configure system preferences and users
|
||||
{t("commandPalette.adminSettingsDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
@@ -242,9 +253,11 @@ export function CommandPalette({
|
||||
<User className="size-4 text-accent-brand" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="text-sm font-semibold">User Profile</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{t("commandPalette.userProfile")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Manage your account and preferences
|
||||
{t("commandPalette.userProfileDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
@@ -265,9 +278,11 @@ export function CommandPalette({
|
||||
<KeyRound className="size-4 text-accent-brand" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="text-sm font-semibold">Add Credential</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{t("commandPalette.addCredential")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Store SSH keys or passwords
|
||||
{t("commandPalette.addCredentialDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
@@ -276,7 +291,10 @@ export function CommandPalette({
|
||||
{recentActivity.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator className="my-2" />
|
||||
<CommandGroup heading="Recent Activity" className="px-2">
|
||||
<CommandGroup
|
||||
heading={t("commandPalette.recentActivity")}
|
||||
className="px-2"
|
||||
>
|
||||
{recentActivity.map((item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
@@ -315,7 +333,10 @@ export function CommandPalette({
|
||||
|
||||
<CommandSeparator className="my-2" />
|
||||
|
||||
<CommandGroup heading="Servers & Hosts" className="px-2">
|
||||
<CommandGroup
|
||||
heading={t("commandPalette.serversAndHosts")}
|
||||
className="px-2"
|
||||
>
|
||||
{filteredHosts.length > 0 ? (
|
||||
groupedHosts.map(({ folder, hosts: groupHosts }) => (
|
||||
<div key={folder ?? "__root__"}>
|
||||
@@ -448,14 +469,14 @@ export function CommandPalette({
|
||||
))
|
||||
) : (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No hosts found matching “{search}”
|
||||
{t("commandPalette.noHostsFound", { search })}
|
||||
</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator className="my-2" />
|
||||
|
||||
<CommandGroup heading="Links" className="px-2">
|
||||
<CommandGroup heading={t("commandPalette.links")} className="px-2">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<CommandItem
|
||||
onSelect={() =>
|
||||
@@ -501,15 +522,15 @@ export function CommandPalette({
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Kbd className="h-5 px-1 bg-background rounded-none">↑↓</Kbd>
|
||||
<span>Navigate</span>
|
||||
<span>{t("commandPalette.navigate")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Kbd className="h-5 px-1 bg-background rounded-none">ENTER</Kbd>
|
||||
<span>Select</span>
|
||||
<span>{t("commandPalette.select")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Toggle with</span>
|
||||
<span>{t("commandPalette.toggleWith")}</span>
|
||||
<Kbd className="h-5 px-1.5 bg-background rounded-none">Shift</Kbd>
|
||||
<span>+</span>
|
||||
<Kbd className="h-5 px-1.5 bg-background rounded-none">Shift</Kbd>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { splitDragState, notifyDragEnd } from "@/lib/splitDragging";
|
||||
import { renderTabContent, tabIcon } from "@/shell/tabUtils";
|
||||
import type { Tab, TabType, Host, SplitMode } from "@/types/ui-types";
|
||||
@@ -253,6 +254,7 @@ function PaneHeader({
|
||||
tab: Tab | null;
|
||||
paneIndex: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2.5 h-7 shrink-0 bg-sidebar border-b border-border text-xs font-medium text-muted-foreground select-none">
|
||||
{tab ? (
|
||||
@@ -263,13 +265,16 @@ function PaneHeader({
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="opacity-40">Pane {paneIndex + 1} — empty</span>
|
||||
<span className="opacity-40">
|
||||
{t("splitScreen.paneEmpty", { index: paneIndex + 1 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPane() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/30 bg-background">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
@@ -278,7 +283,7 @@ function EmptyPane() {
|
||||
<div className="size-5 border-2 border-current rounded-sm" />
|
||||
<div className="size-5 border-2 border-current rounded-sm" />
|
||||
</div>
|
||||
<span className="text-xs">No tab assigned</span>
|
||||
<span className="text-xs">{t("splitScreen.noTabAssigned")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ export function renderTabContent(
|
||||
onOpenSingletonTab?: (type: TabType) => void,
|
||||
onOpenTab?: (host: Host, type: TabType) => void,
|
||||
onCloseTab?: (id: string) => void,
|
||||
isVisible = true,
|
||||
) {
|
||||
const { host, label } = tab;
|
||||
|
||||
@@ -144,7 +145,7 @@ export function renderTabContent(
|
||||
sshPort: host.sshPort ?? host.port,
|
||||
} as any
|
||||
}
|
||||
isVisible={true}
|
||||
isVisible={isVisible}
|
||||
title={label}
|
||||
showTitle={false}
|
||||
splitScreen={false}
|
||||
@@ -170,7 +171,7 @@ export function renderTabContent(
|
||||
<DockerManager
|
||||
hostConfig={hostToSSHHost(host)}
|
||||
title={label}
|
||||
isVisible={true}
|
||||
isVisible={isVisible}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
@@ -185,7 +186,7 @@ export function renderTabContent(
|
||||
<ServerStats
|
||||
hostConfig={hostToSSHHost(host) as any}
|
||||
title={label}
|
||||
isVisible={true}
|
||||
isVisible={isVisible}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { Status, StatusIndicator } from "@/components/shadcn-io/status";
|
||||
import { Button } from "@/components/button";
|
||||
import { ButtonGroup } from "@/components/button-group";
|
||||
import {
|
||||
EllipsisVertical,
|
||||
Terminal,
|
||||
Monitor,
|
||||
Eye,
|
||||
MessagesSquare,
|
||||
Server,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
ArrowDownUp,
|
||||
Container,
|
||||
Power,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/dropdown-menu";
|
||||
import { useTabs } from "@/shell/TabContext";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getGuacamoleDpi,
|
||||
getGuacamoleTokenFromHost,
|
||||
logActivity,
|
||||
wakeOnLan,
|
||||
} from "@/main-axios";
|
||||
import type { HostProps } from "@/types";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHostStatus } from "@/lib/ServerStatusContext";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function Host({ host: initialHost }: HostProps): React.ReactElement {
|
||||
const { addTab } = useTabs();
|
||||
const [host, setHost] = useState(initialHost);
|
||||
const { t } = useTranslation();
|
||||
const [showTags, setShowTags] = useState<boolean>(() => {
|
||||
const saved = localStorage.getItem("showHostTags");
|
||||
return saved !== null ? saved === "true" : true;
|
||||
});
|
||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||
const hasTags = tags.length > 0;
|
||||
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
|
||||
useEffect(() => {
|
||||
setHost(initialHost);
|
||||
}, [initialHost]);
|
||||
|
||||
const hostIdRef = React.useRef(host.id);
|
||||
|
||||
React.useEffect(() => {
|
||||
hostIdRef.current = host.id;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleHostsChanged = async () => {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostIdRef.current);
|
||||
if (updatedHost) {
|
||||
setHost(updatedHost);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleShowTagsChanged = () => {
|
||||
const saved = localStorage.getItem("showHostTags");
|
||||
setShowTags(saved !== null ? saved === "true" : true);
|
||||
};
|
||||
|
||||
window.addEventListener("showHostTagsChanged", handleShowTagsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("showHostTagsChanged", handleShowTagsChanged);
|
||||
}, []);
|
||||
|
||||
const statsConfig = useMemo(() => {
|
||||
if (!host.statsConfig) {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
if (typeof host.statsConfig === "object") {
|
||||
return host.statsConfig;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(host.statsConfig);
|
||||
} catch {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
}, [host.statsConfig]);
|
||||
const shouldShowStatus = ![false, "false"].includes(
|
||||
statsConfig.statusCheckEnabled,
|
||||
);
|
||||
const shouldShowMetrics = statsConfig.metricsEnabled !== false;
|
||||
|
||||
const serverStatus = useHostStatus(host.id, shouldShowStatus);
|
||||
|
||||
const hasTunnelConnections = useMemo(() => {
|
||||
if (!host.tunnelConnections) return false;
|
||||
try {
|
||||
const tunnelConnections = Array.isArray(host.tunnelConnections)
|
||||
? host.tunnelConnections
|
||||
: JSON.parse(host.tunnelConnections);
|
||||
return Array.isArray(tunnelConnections) && tunnelConnections.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [host.tunnelConnections]);
|
||||
|
||||
const handleTerminalClick = async () => {
|
||||
if (
|
||||
host.connectionType === "rdp" ||
|
||||
host.connectionType === "vnc" ||
|
||||
host.connectionType === "telnet"
|
||||
) {
|
||||
try {
|
||||
const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
|
||||
const result = await getGuacamoleTokenFromHost(host.id);
|
||||
addTab({
|
||||
type: protocol,
|
||||
title,
|
||||
hostConfig: host,
|
||||
connectionConfig: {
|
||||
token: result.token,
|
||||
protocol,
|
||||
type: protocol,
|
||||
hostname: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
domain: host.domain,
|
||||
security: host.security,
|
||||
"ignore-cert": host.ignoreCert,
|
||||
dpi: getGuacamoleDpi(host),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await logActivity(protocol, host.id, title);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to log ${protocol} activity:`, err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to get Guacamole token:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
addTab({ type: "terminal", title, hostConfig: host });
|
||||
};
|
||||
|
||||
const isSSH = !host.connectionType || host.connectionType === "ssh";
|
||||
|
||||
const visibleButtons = [
|
||||
host.enableTerminal && (host.showTerminalInSidebar ?? true),
|
||||
isSSH && host.enableFileManager && (host.showFileManagerInSidebar ?? false),
|
||||
isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
(host.showTunnelInSidebar ?? false),
|
||||
isSSH && host.enableDocker && (host.showDockerInSidebar ?? false),
|
||||
isSSH && shouldShowMetrics && (host.showServerStatsInSidebar ?? false),
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{shouldShowStatus && (
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
)}
|
||||
|
||||
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||
{host.name || host.ip}
|
||||
</p>
|
||||
|
||||
<ButtonGroup className="flex-shrink-0">
|
||||
{host.enableTerminal && (host.showTerminalInSidebar ?? true) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={handleTerminalClick}
|
||||
>
|
||||
{host.connectionType === "rdp" ? (
|
||||
<Monitor />
|
||||
) : host.connectionType === "vnc" ? (
|
||||
<Eye />
|
||||
) : host.connectionType === "telnet" ? (
|
||||
<MessagesSquare />
|
||||
) : (
|
||||
<Terminal />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isSSH &&
|
||||
host.enableFileManager &&
|
||||
(host.showFileManagerInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "file_manager", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<FolderOpen />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
(host.showTunnelInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "tunnel", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<ArrowDownUp />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isSSH &&
|
||||
host.enableDocker &&
|
||||
(host.showDockerInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "docker", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<Container />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isSSH &&
|
||||
shouldShowMetrics &&
|
||||
(host.showServerStatsInSidebar ?? false) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-edge"
|
||||
onClick={() =>
|
||||
addTab({ type: "server_stats", title, hostConfig: host })
|
||||
}
|
||||
>
|
||||
<Server />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"!px-2 border-1 border-edge",
|
||||
visibleButtons > 0 && "rounded-l-none border-l-0",
|
||||
)}
|
||||
>
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
side="right"
|
||||
className="w-56 bg-canvas border-edge text-foreground"
|
||||
>
|
||||
{host.enableTerminal && !(host.showTerminalInSidebar ?? true) && (
|
||||
<DropdownMenuItem
|
||||
onClick={handleTerminalClick}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
{host.connectionType === "rdp" ? (
|
||||
<Monitor className="h-4 w-4" />
|
||||
) : host.connectionType === "vnc" ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : host.connectionType === "telnet" ? (
|
||||
<MessagesSquare className="h-4 w-4" />
|
||||
) : (
|
||||
<Terminal className="h-4 w-4" />
|
||||
)}
|
||||
<span className="flex-1">
|
||||
{host.connectionType === "rdp"
|
||||
? t("hosts.openRdp")
|
||||
: host.connectionType === "vnc"
|
||||
? t("hosts.openVnc")
|
||||
: host.connectionType === "telnet"
|
||||
? t("hosts.openTelnet")
|
||||
: t("hosts.openTerminal")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSSH &&
|
||||
shouldShowMetrics &&
|
||||
!(host.showServerStatsInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "server_stats", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openServerStats")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSSH &&
|
||||
host.enableFileManager &&
|
||||
!(host.showFileManagerInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "file_manager", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openFileManager")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSSH &&
|
||||
host.enableTunnel &&
|
||||
hasTunnelConnections &&
|
||||
!(host.showTunnelInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "tunnel", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<ArrowDownUp className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openTunnels")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isSSH &&
|
||||
host.enableDocker &&
|
||||
!(host.showDockerInSidebar ?? false) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({ type: "docker", title, hostConfig: host })
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Container className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.openDocker")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{host.macAddress && (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
await wakeOnLan(host.id);
|
||||
toast.success(t("hosts.wolSent"));
|
||||
} catch {
|
||||
toast.error(t("hosts.wolFailed"));
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
<span className="flex-1">{t("hosts.wakeOnLan")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
addTab({
|
||||
type: "ssh_manager",
|
||||
title: t("nav.hostManager"),
|
||||
hostConfig: host,
|
||||
initialTab: "hosts",
|
||||
})
|
||||
}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="flex-1">{t("common.edit")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
{showTags && hasTags && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{tags.map((tag: string) => (
|
||||
<div
|
||||
key={tag}
|
||||
className="bg-canvas border-1 border-edge pl-2 pr-2 rounded-[10px]"
|
||||
>
|
||||
<p className="text-sm">{tag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1287
-831
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, Settings, X } from "lucide-react";
|
||||
import { SidebarTree } from "@/sidebar/SidebarTree";
|
||||
import { HostManager } from "@/sidebar/HostManager";
|
||||
@@ -24,6 +25,7 @@ export function HostsPanel({
|
||||
onEditHost: (host: Host) => void;
|
||||
hostTree?: HostFolder;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [hostSearch, setHostSearch] = useState("");
|
||||
|
||||
if (expanded) {
|
||||
@@ -44,7 +46,7 @@ export function HostsPanel({
|
||||
<input
|
||||
value={hostSearch}
|
||||
onChange={(e) => setHostSearch(e.target.value)}
|
||||
placeholder="Search hosts..."
|
||||
placeholder={t("hosts.searchHosts")}
|
||||
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
|
||||
/>
|
||||
{hostSearch && (
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { Shield, AlertTriangle, Copy, Check } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -57,9 +49,8 @@ export function HostKeyVerificationDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const formatFingerprint = (fp: string) => {
|
||||
return fp.match(/.{1,2}/g)?.join(":") || fp;
|
||||
};
|
||||
const formatFingerprint = (fp: string) =>
|
||||
fp.match(/.{1,2}/g)?.join(":") || fp;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
@@ -67,119 +58,125 @@ export function HostKeyVerificationDialog({
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<Card className="w-full max-w-2xl mx-4 border-2 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<CardHeader>
|
||||
<div className="bg-card border border-border w-full max-w-lg mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
{scenario === "new" ? (
|
||||
<Shield className="w-5 h-5" />
|
||||
<Shield className="size-4 text-accent-brand" />
|
||||
) : (
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
<AlertTriangle className="size-4 text-destructive" />
|
||||
)}
|
||||
<CardTitle>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{scenario === "new"
|
||||
? t("hostKey.verifyNewHost")
|
||||
: t("hostKey.keyChangedWarning")}
|
||||
</CardTitle>
|
||||
</h3>
|
||||
</div>
|
||||
<CardDescription>
|
||||
<p className="text-[10px] font-mono font-bold tracking-tight text-muted-foreground mt-1">
|
||||
{hostname || ip}:{port}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
{scenario === "new" ? (
|
||||
<>
|
||||
<Alert>
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertTitle>{t("hostKey.firstConnectionTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("hostKey.firstConnectionDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{t("hostKey.fingerprint")} ({algorithm.toUpperCase()})
|
||||
<div className="flex items-start gap-3 p-3 border border-border bg-muted/10">
|
||||
<Shield className="size-4 text-accent-brand shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest">
|
||||
{t("hostKey.firstConnectionTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("hostKey.firstConnectionDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hostKey.fingerprint")} ({algorithm.toUpperCase()})
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 rounded-md bg-muted p-3 font-mono text-xs break-all">
|
||||
<div className="flex-1 bg-muted/50 border border-border p-3 font-mono text-xs break-all">
|
||||
{formatFingerprint(fingerprint)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(fingerprint)}
|
||||
className="shrink-0"
|
||||
className="rounded-none shrink-0"
|
||||
>
|
||||
{copiedFingerprint ? (
|
||||
<Check className="h-4 w-4" />
|
||||
<Check className="size-4 text-accent-brand" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("hostKey.verifyInstructions")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hostKey.verifyInstructions")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>{t("hostKey.securityWarning")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("hostKey.keyChangedDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex items-start gap-3 p-3 border border-destructive/20 bg-destructive/10">
|
||||
<AlertTriangle className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-destructive">
|
||||
{t("hostKey.securityWarning")}
|
||||
</p>
|
||||
<p className="text-xs text-destructive/80 mt-1">
|
||||
{t("hostKey.keyChangedDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hostKey.previousKey")}
|
||||
</div>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 rounded-md bg-muted p-3 font-mono text-xs break-all">
|
||||
<div className="flex-1 bg-muted/50 border border-border p-3 font-mono text-xs break-all">
|
||||
{formatFingerprint(oldFingerprint || "")}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
copyToClipboard(oldFingerprint || "", true)
|
||||
}
|
||||
className="shrink-0"
|
||||
className="rounded-none shrink-0"
|
||||
>
|
||||
{copiedOldFingerprint ? (
|
||||
<Check className="h-4 w-4" />
|
||||
<Check className="size-4 text-accent-brand" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hostKey.newFingerprint")}
|
||||
</div>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 rounded-md bg-muted p-3 font-mono text-xs break-all">
|
||||
<div className="flex-1 bg-muted/50 border border-border p-3 font-mono text-xs break-all">
|
||||
{formatFingerprint(fingerprint)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(fingerprint)}
|
||||
className="shrink-0"
|
||||
className="rounded-none shrink-0"
|
||||
>
|
||||
{copiedFingerprint ? (
|
||||
<Check className="h-4 w-4" />
|
||||
<Check className="size-4 text-accent-brand" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -187,29 +184,33 @@ export function HostKeyVerificationDialog({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onReject}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
variant={scenario === "changed" ? "destructive" : "default"}
|
||||
className="flex-1"
|
||||
>
|
||||
{scenario === "new"
|
||||
? t("hostKey.acceptAndContinue")
|
||||
: t("hostKey.acceptNewKey")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="p-4 border-t border-border flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onReject}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
variant="outline"
|
||||
className={
|
||||
scenario === "changed"
|
||||
? "border-destructive/40 text-destructive hover:bg-destructive/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
: "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
}
|
||||
>
|
||||
{scenario === "new"
|
||||
? t("hostKey.acceptAndContinue")
|
||||
: t("hostKey.acceptNewKey")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export function OPKSSHDialog({
|
||||
backgroundColor,
|
||||
}: OPKSSHDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -36,30 +37,34 @@ export function OPKSSHDialog({
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-xl w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("terminal.opksshAuthRequired")}
|
||||
</h3>
|
||||
<div className="bg-card border border-border w-full max-w-md mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("terminal.opksshAuthRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
{stage === "chooser" && (
|
||||
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
|
||||
{t("terminal.opksshAuthDescription")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
{stage === "chooser" && (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
{t("terminal.opksshAuthDescription")}
|
||||
</p>
|
||||
{providers && providers.length > 0 && onSelectProvider ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{providers.map((provider) => (
|
||||
<Button
|
||||
key={provider.alias}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onSelectProvider(provider.alias)}
|
||||
className="w-full flex items-center justify-center gap-2"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest w-full flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<ExternalLink className="size-3.5" />
|
||||
{t("terminal.opksshSignInWith", {
|
||||
provider:
|
||||
provider.alias.charAt(0).toUpperCase() +
|
||||
@@ -67,61 +72,74 @@ export function OPKSSHDialog({
|
||||
})}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : authUrl ? (
|
||||
<div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onOpenUrl}
|
||||
className="flex-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("terminal.opksshOpenBrowser")}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onOpenUrl}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest w-full flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
{t("terminal.opksshOpenBrowser")}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(stage === "waiting" || stage === "authenticating") && (
|
||||
<div className="flex items-center gap-3 py-4">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">
|
||||
{stage === "waiting"
|
||||
? t("terminal.opksshWaitingForAuth")
|
||||
: t("terminal.opksshAuthenticating")}
|
||||
</p>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Loader2 className="size-4 animate-spin text-accent-brand shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stage === "waiting"
|
||||
? t("terminal.opksshWaitingForAuth")
|
||||
: t("terminal.opksshAuthenticating")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{stage === "error" && error && (
|
||||
<>
|
||||
<div className="flex items-start gap-3 p-4 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<AlertCircle className="w-5 h-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
<div className="flex items-start gap-3 p-3 border border-destructive/20 bg-destructive/10">
|
||||
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-destructive">
|
||||
{t("common.error")}
|
||||
</p>
|
||||
<p className="text-sm text-destructive/90 mt-1 whitespace-pre-wrap break-words">
|
||||
<p className="text-xs text-destructive/90 mt-1 whitespace-pre-wrap break-words">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -28,58 +27,63 @@ export function PassphraseDialog({
|
||||
? `${hostInfo.name} (${hostInfo.username}@${hostInfo.ip}:${hostInfo.port})`
|
||||
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.elements.namedItem(
|
||||
"passphrase",
|
||||
) as HTMLInputElement;
|
||||
if (input?.value) {
|
||||
onSubmit(input.value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4">
|
||||
<div className="bg-card border border-border w-full max-w-sm mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
<KeyRound className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("auth.passphraseRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{hostDisplay}</p>
|
||||
<p className="text-[10px] font-mono font-bold tracking-tight text-muted-foreground mt-1">
|
||||
{hostDisplay}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.elements.namedItem(
|
||||
"passphrase",
|
||||
) as HTMLInputElement;
|
||||
if (input && input.value) {
|
||||
onSubmit(input.value);
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="passphrase">
|
||||
{t("auth.passphraseRequiredDescription")}
|
||||
</Label>
|
||||
<form onSubmit={handleSubmit} className="p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<PasswordInput
|
||||
id="passphrase"
|
||||
name="passphrase"
|
||||
autoFocus
|
||||
placeholder={t("placeholders.keyPassword")}
|
||||
className="mt-1.5"
|
||||
className="rounded-none bg-muted/50 border-border text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("auth.passphraseRequiredDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="flex-1">
|
||||
{t("common.connect")}
|
||||
</Button>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -46,7 +38,7 @@ export function SSHAuthDialog({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
hostInfo,
|
||||
backgroundColor = "var(--bg-base)",
|
||||
backgroundColor,
|
||||
}: SSHAuthDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [authTab, setAuthTab] = useState<"password" | "key">("password");
|
||||
@@ -57,6 +49,10 @@ export function SSHAuthDialog({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const hostDisplay = hostInfo.name
|
||||
? `${hostInfo.name} (${hostInfo.username}@${hostInfo.ip}:${hostInfo.port})`
|
||||
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
|
||||
|
||||
const getReasonMessage = () => {
|
||||
switch (reason) {
|
||||
case "no_keyboard":
|
||||
@@ -83,30 +79,26 @@ export function SSHAuthDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = () =>
|
||||
authTab === "password" ? password !== "" : sshKey.trim() !== "";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
} = {};
|
||||
|
||||
if (authTab === "password") {
|
||||
if (password !== "") {
|
||||
credentials.password = password;
|
||||
}
|
||||
if (password !== "") credentials.password = password;
|
||||
} else {
|
||||
if (sshKey.trim()) {
|
||||
credentials.sshKey = sshKey;
|
||||
if (keyPassword.trim()) {
|
||||
credentials.keyPassword = keyPassword;
|
||||
}
|
||||
if (keyPassword.trim()) credentials.keyPassword = keyPassword;
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(credentials);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -119,110 +111,128 @@ export function SSHAuthDialog({
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
const fileContent = await file.text();
|
||||
setSshKey(fileContent);
|
||||
setSshKey(await file.text());
|
||||
} catch (error) {
|
||||
console.error("Failed to read SSH key file:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = () => {
|
||||
if (authTab === "password") {
|
||||
return password !== "";
|
||||
} else {
|
||||
return sshKey.trim() !== "";
|
||||
}
|
||||
};
|
||||
|
||||
const hostDisplay = hostInfo.name
|
||||
? `${hostInfo.name} (${hostInfo.username}@${hostInfo.ip}:${hostInfo.port})`
|
||||
: `${hostInfo.username}@${hostInfo.ip}:${hostInfo.port}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-9999 flex items-center justify-center bg-canvas animate-in fade-in duration-200"
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<Card className="w-full max-w-2xl mx-4 border-2 animate-in fade-in zoom-in-95 duration-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
{t("auth.sshAuthenticationRequired")}
|
||||
</CardTitle>
|
||||
<CardDescription>{hostDisplay}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant={reason === "auth_failed" ? "destructive" : "default"}>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{getReasonMessage()}</AlertTitle>
|
||||
<AlertDescription>{getReasonDescription()}</AlertDescription>
|
||||
</Alert>
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-card border border-border w-full max-w-xl mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("auth.sshAuthenticationRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[10px] font-mono font-bold tracking-tight text-muted-foreground mt-1">
|
||||
{hostDisplay}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div
|
||||
className={`flex items-start gap-3 p-3 border ${
|
||||
reason === "auth_failed"
|
||||
? "border-destructive/20 bg-destructive/10"
|
||||
: "border-border bg-muted/10"
|
||||
}`}
|
||||
>
|
||||
<AlertCircle
|
||||
className={`size-4 shrink-0 mt-0.5 ${reason === "auth_failed" ? "text-destructive" : "text-accent-brand"}`}
|
||||
/>
|
||||
<div>
|
||||
<p
|
||||
className={`text-[10px] font-bold uppercase tracking-widest ${reason === "auth_failed" ? "text-destructive" : ""}`}
|
||||
>
|
||||
{getReasonMessage()}
|
||||
</p>
|
||||
<p
|
||||
className={`text-xs mt-1 ${reason === "auth_failed" ? "text-destructive/80" : "text-muted-foreground"}`}
|
||||
>
|
||||
{getReasonDescription()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Tabs
|
||||
value={authTab}
|
||||
onValueChange={(v) => setAuthTab(v as "password" | "key")}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="password">
|
||||
<TabsList className="w-full rounded-none">
|
||||
<TabsTrigger
|
||||
value="password"
|
||||
className="flex-1 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("credentials.password")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="key">{t("credentials.sshKey")}</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="key"
|
||||
className="flex-1 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("credentials.sshKey")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="password" className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ssh-password">
|
||||
{t("credentials.password")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="ssh-password"
|
||||
placeholder={t("placeholders.enterPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("auth.sshPasswordDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<TabsContent
|
||||
value="password"
|
||||
className="mt-3 flex flex-col gap-2"
|
||||
>
|
||||
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("credentials.password")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
placeholder={t("placeholders.enterPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
className="rounded-none bg-muted/50 border-border text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("auth.sshPasswordDescription")}
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="key" className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ssh-key">
|
||||
<TabsContent value="key" className="mt-3 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("credentials.sshPrivateKey")}
|
||||
</Label>
|
||||
<div className="mb-2">
|
||||
<div className="relative inline-block w-full">
|
||||
<input
|
||||
id="key-upload"
|
||||
type="file"
|
||||
accept="*,.pem,.key,.txt,.ppk"
|
||||
onChange={handleKeyFileUpload}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
<span className="truncate">
|
||||
{t("credentials.uploadPrivateKeyFile")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="key-upload"
|
||||
type="file"
|
||||
accept="*,.pem,.key,.txt,.ppk"
|
||||
onChange={handleKeyFileUpload}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start rounded-none text-[10px] font-bold uppercase tracking-widest border-border"
|
||||
>
|
||||
<Upload className="size-3.5 mr-2" />
|
||||
<span className="truncate">
|
||||
{t("credentials.uploadPrivateKeyFile")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<CodeMirror
|
||||
value={sshKey}
|
||||
onChange={(value) => setSshKey(value)}
|
||||
placeholder={t("placeholders.pastePrivateKey")}
|
||||
theme={oneDark}
|
||||
className="border border-input rounded-md"
|
||||
minHeight="200px"
|
||||
maxHeight="300px"
|
||||
className="border border-border text-xs"
|
||||
minHeight="160px"
|
||||
maxHeight="260px"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: false,
|
||||
@@ -245,44 +255,45 @@ export function SSHAuthDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ssh-key-password">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("credentials.keyPassword")} ({t("common.optional")})
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="ssh-key-password"
|
||||
placeholder={t("placeholders.keyPassword")}
|
||||
value={keyPassword}
|
||||
onChange={(e) => setKeyPassword(e.target.value)}
|
||||
className="rounded-none bg-muted/50 border-border text-xs"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("auth.sshKeyPasswordDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit() || loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? t("common.connecting") : t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="p-4 border-t border-border flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!canSubmit() || loading}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{loading ? t("common.connecting") : t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Shield } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -23,59 +22,62 @@ export function TOTPDialog({
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.elements.namedItem(
|
||||
"totpCode",
|
||||
) as HTMLInputElement;
|
||||
if (input?.value.trim()) {
|
||||
onSubmit(input.value.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4">
|
||||
<div className="bg-card border border-border w-full max-w-sm mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Shield className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("terminal.totpRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
|
||||
{t("terminal.totpCodeLabel")}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.elements.namedItem(
|
||||
"totpCode",
|
||||
) as HTMLInputElement;
|
||||
if (input && input.value.trim()) {
|
||||
onSubmit(input.value.trim());
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="totpCode">{t("terminal.totpCodeLabel")}</Label>
|
||||
<Input
|
||||
id="totpCode"
|
||||
name="totpCode"
|
||||
type="text"
|
||||
autoFocus
|
||||
maxLength={6}
|
||||
pattern="[0-9]*"
|
||||
inputMode="numeric"
|
||||
placeholder="000000"
|
||||
className="text-center text-lg tracking-widest mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="flex-1">
|
||||
{t("terminal.totpVerify")}
|
||||
</Button>
|
||||
<form onSubmit={handleSubmit} className="p-4 flex flex-col gap-4">
|
||||
<Input
|
||||
id="totpCode"
|
||||
name="totpCode"
|
||||
type="text"
|
||||
autoFocus
|
||||
maxLength={6}
|
||||
pattern="[0-9]*"
|
||||
inputMode="numeric"
|
||||
placeholder="000000"
|
||||
className="rounded-none bg-muted/50 border-border text-center text-sm tracking-widest"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("terminal.totpVerify")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Terminal, Monitor, Users, Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -56,34 +57,32 @@ export function TmuxSessionPicker({
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4">
|
||||
<div className="bg-card border border-border w-full max-w-sm mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Terminal className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("terminal.tmuxSessionPickerTitle")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
|
||||
{t("terminal.tmuxSessionPickerDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4 max-h-60 overflow-y-auto">
|
||||
<div className="flex flex-col max-h-60 overflow-y-auto">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.name}
|
||||
onClick={() => onSelect(session.name)}
|
||||
className="w-full text-left px-3 py-3 rounded-md border border-edge hover:bg-muted transition-colors"
|
||||
className="w-full text-left px-4 py-3 border-b border-border hover:bg-muted/50 transition-colors last:border-b-0"
|
||||
>
|
||||
<div className="font-mono text-sm font-medium">
|
||||
{session.name}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-muted-foreground">
|
||||
<div className="font-mono text-xs font-bold">{session.name}</div>
|
||||
<div className="flex gap-3 mt-1 text-[10px] text-muted-foreground">
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxWindows")}
|
||||
>
|
||||
<Monitor className="w-3 h-3" />
|
||||
<Monitor className="size-3" />
|
||||
{t("terminal.tmuxWindowCount", { count: session.windows })}
|
||||
</span>
|
||||
{session.attachedClients > 0 && (
|
||||
@@ -91,7 +90,7 @@ export function TmuxSessionPicker({
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxAttached")}
|
||||
>
|
||||
<Users className="w-3 h-3" />
|
||||
<Users className="size-3" />
|
||||
{t("terminal.tmuxAttachedCount", {
|
||||
count: session.attachedClients,
|
||||
})}
|
||||
@@ -101,20 +100,28 @@ export function TmuxSessionPicker({
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxLastActivity")}
|
||||
>
|
||||
<Clock className="w-3 h-3" />
|
||||
<Clock className="size-3" />
|
||||
{formatTimestamp(session.lastActivity, t)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onCreateNew} variant="outline" className="flex-1">
|
||||
{t("terminal.tmuxCreateNew")}
|
||||
</Button>
|
||||
<Button onClick={onCancel} variant="outline" className="flex-1">
|
||||
<div className="p-4 border-t border-border flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="ghost"
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCreateNew}
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("terminal.tmuxCreateNew")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Shield, Copy, ExternalLink } from "lucide-react";
|
||||
import { Shield, Copy, ExternalLink, Check } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -47,74 +46,80 @@ export function WarpgateDialog({
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-xl w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("terminal.warpgateAuthRequired")}
|
||||
</h3>
|
||||
<div className="bg-card border border-border w-full max-w-md mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("terminal.warpgateAuthRequired")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-base font-semibold mb-2 block">
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("terminal.warpgateSecurityKey")}
|
||||
</Label>
|
||||
<div className="bg-base border-2 border-accent rounded-md p-4 text-center">
|
||||
<div className="text-3xl font-mono font-bold tracking-wider text-primary">
|
||||
</p>
|
||||
<div className="border border-border bg-muted/10 p-4 text-center">
|
||||
<div className="text-2xl font-mono font-bold tracking-wider text-accent-brand">
|
||||
{securityKey}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="warpgateUrl" className="text-base font-semibold">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("terminal.warpgateAuthUrl")}
|
||||
</Label>
|
||||
<div className="flex gap-2 mt-2">
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="warpgateUrl"
|
||||
type="text"
|
||||
value={url}
|
||||
readOnly
|
||||
className="flex-1 font-mono text-xs"
|
||||
className="rounded-none bg-muted/50 border-border text-xs font-mono flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyUrl}
|
||||
className="rounded-none border-border shrink-0"
|
||||
title={t("common.copy")}
|
||||
>
|
||||
<Copy className={`w-4 h-4 ${copied ? "text-success" : ""}`} />
|
||||
{copied ? (
|
||||
<Check className="size-4 text-accent-brand" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-2">
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onOpenUrl}
|
||||
className="flex-1 flex items-center justify-center gap-2"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest sm:mr-auto"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t("terminal.warpgateOpenBrowser")}
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
onClick={onContinue}
|
||||
className="flex-1"
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("terminal.warpgateContinue")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="sm:w-auto"
|
||||
onClick={onOpenUrl}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest flex items-center gap-1.5"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
<ExternalLink className="size-3.5" />
|
||||
{t("terminal.warpgateOpenBrowser")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -693,7 +693,7 @@ export function C2STunnelPresetManager(): React.ReactElement {
|
||||
setLocalConfig((current) => [...current, createClientTunnel()])
|
||||
}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
<Plus className="size-3.5 mr-1.5" />
|
||||
{t("tunnels.addClientTunnel")}
|
||||
</Button>
|
||||
{hasUnsavedLocalChanges && (
|
||||
@@ -702,7 +702,7 @@ export function C2STunnelPresetManager(): React.ReactElement {
|
||||
</span>
|
||||
)}
|
||||
<Button type="button" onClick={handleSaveLocal}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
<Save className="size-3.5 mr-1.5" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -738,7 +738,10 @@ export function C2STunnelPresetManager(): React.ReactElement {
|
||||
const lastTested = formatDateTime(tunnel.lastTestedAt);
|
||||
|
||||
return (
|
||||
<div key={index} className="p-4 border rounded-lg bg-muted/50">
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 border border-border bg-muted/20"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-[240px] flex-1 space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
@@ -752,7 +755,7 @@ export function C2STunnelPresetManager(): React.ReactElement {
|
||||
})
|
||||
}
|
||||
placeholder={getTunnelDisplayName(tunnel, index)}
|
||||
className="h-8 max-w-md bg-background"
|
||||
className="h-8 max-w-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
@@ -766,9 +769,7 @@ export function C2STunnelPresetManager(): React.ReactElement {
|
||||
className="h-8 px-3 text-xs"
|
||||
>
|
||||
<Activity
|
||||
className={`h-3 w-3 mr-1 ${
|
||||
isTunnelTestLoading ? "animate-pulse" : ""
|
||||
}`}
|
||||
className={`size-3 mr-1 ${isTunnelTestLoading ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
{t("tunnels.test")}
|
||||
</Button>
|
||||
@@ -784,6 +785,7 @@ export function C2STunnelPresetManager(): React.ReactElement {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() =>
|
||||
setLocalConfig((current) =>
|
||||
current.filter(
|
||||
@@ -792,7 +794,7 @@ export function C2STunnelPresetManager(): React.ReactElement {
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
<Trash2 className="size-3.5 mr-1.5" />
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -895,7 +897,7 @@ export function C2STunnelPresetManager(): React.ReactElement {
|
||||
{modeDescription}
|
||||
</p>
|
||||
<div
|
||||
className="mt-2 rounded-md border bg-canvasX px-3 py-2 text-xs text-muted-foreground"
|
||||
className="mt-2 border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"
|
||||
title={tunnelSummary}
|
||||
>
|
||||
<span className="font-medium text-foreground">
|
||||
@@ -921,10 +923,7 @@ export function C2STunnelPresetManager(): React.ReactElement {
|
||||
</span>
|
||||
)}
|
||||
{lastError && (
|
||||
<span
|
||||
className="text-red-600 dark:text-red-400"
|
||||
title={lastError}
|
||||
>
|
||||
<span className="text-destructive" title={lastError}>
|
||||
{t("tunnels.lastError")}: {lastError}
|
||||
</span>
|
||||
)}
|
||||
@@ -965,7 +964,7 @@ export function C2STunnelPresetManager(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 space-y-2">
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border bg-canvas p-3">
|
||||
<div className="flex items-center justify-between gap-3 border border-border bg-muted/20 px-3 py-2.5">
|
||||
<Label>{t("tunnels.autoStart")}</Label>
|
||||
<Switch
|
||||
checked={tunnel.autoStart}
|
||||
@@ -1014,7 +1013,7 @@ export function C2STunnelPresetManager(): React.ReactElement {
|
||||
placeholder={t("profile.c2sPresetNamePlaceholder")}
|
||||
/>
|
||||
<Button onClick={handleSavePreset} disabled={!presetName.trim()}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
<Save className="size-3.5 mr-1.5" />
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1063,7 +1062,7 @@ export function C2STunnelPresetManager(): React.ReactElement {
|
||||
onClick={handleLoadPreset}
|
||||
disabled={!selectedPreset || selectedMatchesCurrent}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<Download className="size-3.5 mr-1.5" />
|
||||
{t("profile.c2sLoadPreset")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1071,15 +1070,16 @@ export function C2STunnelPresetManager(): React.ReactElement {
|
||||
onClick={handleRenamePreset}
|
||||
disabled={!selectedPreset || !presetName.trim()}
|
||||
>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
<Pencil className="size-3.5 mr-1.5" />
|
||||
{t("common.rename")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleDeletePreset}
|
||||
disabled={!selectedPreset}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
<Trash2 className="size-3.5 mr-1.5" />
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/card.tsx";
|
||||
import { Key } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { changePassword } from "@/main-axios.ts";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function PasswordReset() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
async function handleChangePassword() {
|
||||
setError(null);
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
setError(t("errors.requiredField"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t("common.passwordsDoNotMatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError(t("common.passwordMinLength"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await changePassword(currentPassword, newPassword);
|
||||
toast.success(t("profile.passwordChangedSuccess"));
|
||||
window.location.reload();
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(
|
||||
error?.response?.data?.error || t("profile.failedToChangePassword"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const Spinner = (
|
||||
<svg
|
||||
className="animate-spin mr-2 h-4 w-4 text-foreground inline-block"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
{t("common.password")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("common.changeAccountPassword")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="current-password">
|
||||
{t("profile.currentPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">{t("common.newPassword")}</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("common.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold mt-2"
|
||||
disabled={
|
||||
loading || !currentPassword || !newPassword || !confirmPassword
|
||||
}
|
||||
onClick={handleChangePassword}
|
||||
>
|
||||
{loading ? Spinner : t("profile.changePassword")}
|
||||
</Button>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,479 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { PasswordInput } from "@/components/password-input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/tabs.tsx";
|
||||
import {
|
||||
Shield,
|
||||
Copy,
|
||||
Download,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
setupTOTP,
|
||||
enableTOTP,
|
||||
disableTOTP,
|
||||
generateBackupCodes,
|
||||
} from "@/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TOTPSetupProps {
|
||||
isEnabled: boolean;
|
||||
onStatusChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function TOTPSetup({
|
||||
isEnabled: initialEnabled,
|
||||
onStatusChange,
|
||||
}: TOTPSetupProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isEnabled, setIsEnabled] = useState(initialEnabled);
|
||||
const [isSettingUp, setIsSettingUp] = useState(false);
|
||||
const [setupStep, setSetupStep] = useState<
|
||||
"init" | "qr" | "verify" | "backup"
|
||||
>("init");
|
||||
const [qrCode, setQrCode] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [disableCode, setDisableCode] = useState("");
|
||||
|
||||
const handleSetupStart = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await setupTOTP();
|
||||
setQrCode(response.qr_code);
|
||||
setSecret(response.secret);
|
||||
setSetupStep("qr");
|
||||
setIsSettingUp(true);
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(error?.response?.data?.error || "Failed to start TOTP setup");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyCode = async () => {
|
||||
if (verificationCode.length !== 6) {
|
||||
setError("Please enter a 6-digit code");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await enableTOTP(verificationCode);
|
||||
setBackupCodes(response.backup_codes);
|
||||
setSetupStep("backup");
|
||||
toast.success(t("auth.twoFactorEnabledSuccess"));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(error?.response?.data?.error || "Invalid verification code");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await disableTOTP(password || undefined, disableCode || undefined);
|
||||
setIsEnabled(false);
|
||||
setIsSettingUp(false);
|
||||
setSetupStep("init");
|
||||
setPassword("");
|
||||
setDisableCode("");
|
||||
onStatusChange?.(false);
|
||||
toast.success(t("auth.twoFactorDisabled"));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(error?.response?.data?.error || "Failed to disable TOTP");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateNewBackupCodes = async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await generateBackupCodes(
|
||||
password || undefined,
|
||||
disableCode || undefined,
|
||||
);
|
||||
setBackupCodes(response.backup_codes);
|
||||
toast.success(t("auth.newBackupCodesGenerated"));
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(
|
||||
error?.response?.data?.error || "Failed to generate backup codes",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(t("messages.copiedToClipboard", { item: label }));
|
||||
};
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
const content =
|
||||
`Termix Two-Factor Authentication Backup Codes\n` +
|
||||
`Generated: ${new Date().toISOString()}\n\n` +
|
||||
`Keep these codes in a safe place. Each code can only be used once.\n\n` +
|
||||
backupCodes.map((code, i) => `${i + 1}. ${code}`).join("\n");
|
||||
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "termix-backup-codes.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("auth.backupCodesDownloaded"));
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
setIsEnabled(true);
|
||||
setIsSettingUp(false);
|
||||
setSetupStep("init");
|
||||
setVerificationCode("");
|
||||
onStatusChange?.(true);
|
||||
};
|
||||
|
||||
if (isEnabled && !isSettingUp) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
{t("auth.twoFactorTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("auth.twoFactorProtected")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.enabled")}</AlertTitle>
|
||||
<AlertDescription>{t("auth.twoFactorActive")}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue="disable" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="disable">{t("auth.disable2FA")}</TabsTrigger>
|
||||
<TabsTrigger value="backup">{t("auth.backupCodes")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="disable" className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.warning")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("auth.disableTwoFactorWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-password">
|
||||
{t("auth.passwordOrTotpCode")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="disable-password"
|
||||
placeholder={t("placeholders.enterPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">{t("auth.or")}</p>
|
||||
<Input
|
||||
id="disable-code"
|
||||
type="text"
|
||||
placeholder={t("placeholders.totpCode")}
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) =>
|
||||
setDisableCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDisable}
|
||||
disabled={loading || (!password && !disableCode)}
|
||||
>
|
||||
{t("auth.disableTwoFactor")}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("auth.generateNewBackupCodesText")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-password">
|
||||
{t("auth.passwordOrTotpCode")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="backup-password"
|
||||
placeholder={t("placeholders.enterPassword")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">{t("auth.or")}</p>
|
||||
<Input
|
||||
id="backup-code"
|
||||
type="text"
|
||||
placeholder={t("placeholders.totpCode")}
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) =>
|
||||
setDisableCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerateNewBackupCodes}
|
||||
disabled={loading || (!password && !disableCode)}
|
||||
>
|
||||
{t("auth.generateNewBackupCodes")}
|
||||
</Button>
|
||||
|
||||
{backupCodes.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>{t("auth.yourBackupCodes")}</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={downloadBackupCodes}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{t("auth.download")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
|
||||
{backupCodes.map((code, i) => (
|
||||
<div key={i}>{code}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupStep === "qr") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("auth.setupTwoFactorTitle")}</CardTitle>
|
||||
<CardDescription>{t("auth.step1ScanQR")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<img src={qrCode} alt="TOTP QR Code" className="w-64 h-64" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("auth.manualEntryCode")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={secret} readOnly className="font-mono text-sm" />
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(secret, "Secret key")}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("auth.cannotScanQRText")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setSetupStep("verify")} className="w-full">
|
||||
{t("auth.nextVerifyCode")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupStep === "verify") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("auth.verifyAuthenticator")}</CardTitle>
|
||||
<CardDescription>{t("auth.step2EnterCode")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="verify-code">{t("auth.verificationCode")}</Label>
|
||||
<Input
|
||||
id="verify-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
value={verificationCode}
|
||||
onChange={(e) =>
|
||||
setVerificationCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSetupStep("qr")}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("auth.back")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerifyCode}
|
||||
disabled={loading || verificationCode.length !== 6}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? t("interface.verifying") : t("auth.verifyAndEnable")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (setupStep === "backup") {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("auth.saveBackupCodesTitle")}</CardTitle>
|
||||
<CardDescription>{t("auth.step3StoreCodesSecurely")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.important")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("auth.importantBackupCodesText")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Your Backup Codes</Label>
|
||||
<Button size="sm" variant="outline" onClick={downloadBackupCodes}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
|
||||
{backupCodes.map((code, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{i + 1}.</span>
|
||||
<span>{code}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleComplete} className="w-full">
|
||||
{t("auth.completeSetup")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
{t("auth.twoFactorTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription className="space-y-2">
|
||||
<p>{t("auth.addExtraSecurityLayer")}.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() =>
|
||||
window.open("https://docs.termix.site/totp", "_blank")
|
||||
}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("common.notEnabled")}</AlertTitle>
|
||||
<AlertDescription>{t("auth.notEnabledText")}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button
|
||||
onClick={handleSetupStart}
|
||||
disabled={loading}
|
||||
className="w-full h-11 text-base"
|
||||
>
|
||||
{loading ? t("common.settingUp") : t("auth.enableTwoFactorButton")}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user