feat: continued improvements

This commit is contained in:
LukeGus
2026-05-14 00:55:12 -05:00
parent 2a45cfea9a
commit c26288e024
51 changed files with 2933 additions and 9103 deletions
+4 -5
View File
@@ -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",
+17 -4
View File
@@ -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,
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+71 -6
View File
@@ -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>
-505
View File
@@ -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;
-163
View File
@@ -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>
);
}
-143
View File
@@ -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>
);
}
-538
View File
@@ -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>
);
}
-508
View File
@@ -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>
);
}
-223
View File
@@ -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>
);
}
-536
View File
@@ -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>
);
}
-339
View File
@@ -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>
);
}
-301
View File
@@ -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>
);
}
-209
View File
@@ -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>
);
}
-177
View File
@@ -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>
);
}
+14 -8
View File
@@ -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>
+6 -2
View File
@@ -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>
);
+86 -18
View File
@@ -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>
);
+46 -2
View File
@@ -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>
);
+30 -5
View File
@@ -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]);
+6 -2
View File
@@ -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>
);
}
-248
View File
@@ -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}
/>
);
}
+42 -14
View File
@@ -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)}
/>
);
}}
+14 -14
View File
@@ -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>
)}
-169
View File
@@ -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>
);
}
+19 -13
View File
@@ -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>
);
-532
View File
@@ -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>
);
}
-64
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+40 -19
View File
@@ -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 &ldquo;{search}&rdquo;
{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>
+7 -2
View File
@@ -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>
);
}
+4 -3
View File
@@ -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}
/>
-411
View File
@@ -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>
);
}
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -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>
);
}
+70 -52
View File
@@ -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>
+33 -29
View File
@@ -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>
+132 -121
View File
@@ -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>
);
}
+39 -37
View File
@@ -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>
+26 -19
View File
@@ -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>
+37 -32
View File
@@ -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>
+18 -18
View File
@@ -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>
-152
View File
@@ -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>
);
}
-479
View File
@@ -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