Update file naming and structure for mobile support

This commit is contained in:
LukeGus
2025-09-03 19:14:57 -05:00
parent 25e5b61b3e
commit 4852b0f884
36 changed files with 49 additions and 49 deletions

View File

@@ -0,0 +1,450 @@
import React from "react";
import {useSidebar} from "@/components/ui/sidebar.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {Checkbox} from "@/components/ui/checkbox.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Label} from "@/components/ui/label.tsx";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import {Shield, Trash2, Users} from "lucide-react";
import {toast} from "sonner";
import {useTranslation} from "react-i18next";
import {
getOIDCConfig,
getRegistrationAllowed,
getUserList,
updateRegistrationAllowed,
updateOIDCConfig,
makeUserAdmin,
removeAdminStatus,
deleteUser
} from "@/ui/main-axios.ts";
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
interface AdminSettingsProps {
isTopbarOpen?: boolean;
}
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
const {t} = useTranslation();
const {state: sidebarState} = useSidebar();
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
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: ''
});
const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null);
const [users, setUsers] = React.useState<Array<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean
}>>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
React.useEffect(() => {
const jwt = getCookie("jwt");
if (!jwt) return;
getOIDCConfig()
.then(res => {
if (res) setOidcConfig(res);
})
.catch(() => {
});
fetchUsers();
}, []);
React.useEffect(() => {
getRegistrationAllowed()
.then(res => {
if (typeof res?.allowed === 'boolean') {
setAllowRegistration(res.allowed);
}
})
.catch(() => {
});
}, []);
const fetchUsers = async () => {
const jwt = getCookie("jwt");
if (!jwt) return;
setUsersLoading(true);
try {
const response = await getUserList();
setUsers(response.users);
} finally {
setUsersLoading(false);
}
};
const handleToggleRegistration = async (checked: boolean) => {
setRegLoading(true);
const jwt = getCookie("jwt");
try {
await updateRegistrationAllowed(checked);
setAllowRegistration(checked);
} finally {
setRegLoading(false);
}
};
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;
}
const jwt = getCookie("jwt");
try {
await updateOIDCConfig(oidcConfig);
toast.success(t('admin.oidcConfigurationUpdated'));
} catch (err: any) {
setOidcError(err?.response?.data?.error || t('admin.failedToUpdateOidcConfig'));
} finally {
setOidcLoading(false);
}
};
const handleOIDCConfigChange = (field: string, value: string) => {
setOidcConfig(prev => ({...prev, [field]: value}));
};
const handleMakeUserAdmin = async (e: React.FormEvent) => {
e.preventDefault();
if (!newAdminUsername.trim()) return;
setMakeAdminLoading(true);
setMakeAdminError(null);
const jwt = getCookie("jwt");
try {
await makeUserAdmin(newAdminUsername.trim());
toast.success(t('admin.userIsNowAdmin', { username: newAdminUsername }));
setNewAdminUsername("");
fetchUsers();
} catch (err: any) {
setMakeAdminError(err?.response?.data?.error || t('admin.failedToMakeUserAdmin'));
} finally {
setMakeAdminLoading(false);
}
};
const handleRemoveAdminStatus = async (username: string) => {
if (!confirm(t('admin.removeAdminStatus', { username }))) return;
const jwt = getCookie("jwt");
try {
await removeAdminStatus(username);
toast.success(t('admin.adminStatusRemoved', { username }));
fetchUsers();
} catch (err: any) {
console.error('Failed to remove admin status:', err);
toast.error(t('admin.failedToRemoveAdminStatus'));
}
};
const handleDeleteUser = async (username: string) => {
if (!confirm(t('admin.deleteUser', { username }))) return;
const jwt = getCookie("jwt");
try {
await deleteUser(username);
toast.success(t('admin.userDeletedSuccessfully', { username }));
fetchUsers();
} catch (err: any) {
console.error('Failed to delete user:', err);
toast.error(t('admin.failedToDeleteUser'));
}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = {
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
};
return (
<div style={wrapperStyle}
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
<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">
<Tabs defaultValue="registration" className="w-full">
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
<TabsTrigger value="registration" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
{t('admin.general')}
</TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
OIDC
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
{t('admin.users')}
</TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
{t('admin.adminManagement')}
</TabsTrigger>
</TabsList>
<TabsContent value="registration" className="space-y-6">
<div className="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}/>
{t('admin.allowNewAccountRegistration')}
</label>
</div>
</TabsContent>
<TabsContent value="oidc" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">{t('admin.externalAuthentication')}</h3>
<p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p>
{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>
<Input id="client_secret" type="password" 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="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={() => setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile',
userinfo_url: ''
})}>{t('admin.reset')}</Button>
</div>
</form>
</div>
</TabsContent>
<TabsContent value="users" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{t('admin.userManagement')}</h3>
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
size="sm">{usersLoading ? t('admin.loading') : t('admin.refresh')}</Button>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">{t('admin.loadingUsers')}</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">{t('admin.username')}</TableHead>
<TableHead className="px-4">{t('admin.type')}</TableHead>
<TableHead className="px-4">{t('admin.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="px-4 font-medium">
{user.username}
{user.is_admin && (
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')}</span>
)}
</TableCell>
<TableCell
className="px-4">{user.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
<TableCell className="px-4">
<Button variant="ghost" size="sm"
onClick={() => handleDeleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}>
<Trash2 className="h-4 w-4"/>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
<TabsContent value="admins" className="space-y-6">
<div className="space-y-6">
<h3 className="text-lg font-semibold">{t('admin.adminManagement')}</h3>
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
<h4 className="font-medium">{t('admin.makeUserAdmin')}</h4>
<form onSubmit={handleMakeUserAdmin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-admin-username">{t('admin.username')}</Label>
<div className="flex gap-2">
<Input id="new-admin-username" value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder={t('admin.enterUsernameToMakeAdmin')} required/>
<Button type="submit"
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? t('admin.adding') : t('admin.makeAdmin')}</Button>
</div>
</div>
{makeAdminError && (
<Alert variant="destructive">
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{makeAdminError}</AlertDescription>
</Alert>
)}
</form>
</div>
<div className="space-y-4">
<h4 className="font-medium">{t('admin.currentAdmins')}</h4>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">{t('admin.username')}</TableHead>
<TableHead className="px-4">{t('admin.type')}</TableHead>
<TableHead className="px-4">{t('admin.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.filter(u => u.is_admin).map((admin) => (
<TableRow key={admin.id}>
<TableCell className="px-4 font-medium">
{admin.username}
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')}</span>
</TableCell>
<TableCell
className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
<TableCell className="px-4">
<Button variant="ghost" size="sm"
onClick={() => handleRemoveAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
<Shield className="h-4 w-4"/>
{t('admin.removeAdminButton')}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}
export default AdminSettings;

View File

@@ -0,0 +1,24 @@
import React from "react";
import { FileManagerTabList } from "./FileManagerTabList.tsx";
interface FileManagerTopNavbarProps {
tabs: {id: string | number, title: string}[];
activeTab: string | number;
setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void;
onHomeClick: () => void;
}
export function FIleManagerTopNavbar(props: FileManagerTopNavbarProps): React.ReactElement {
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
return (
<FileManagerTabList
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
closeTab={closeTab}
onHomeClick={onHomeClick}
/>
);
}

View File

@@ -0,0 +1,692 @@
import React, {useState, useEffect, useRef} from "react";
import {FileManagerLeftSidebar} from "@/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx";
import {FileManagerTabList} from "@/ui/Desktop/Apps/File Manager/FileManagerTabList.tsx";
import {FileManagerHomeView} from "@/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx";
import {FileManagerFileEditor} from "@/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx";
import {FileManagerOperations} from "@/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx";
import {Button} from '@/components/ui/button.tsx';
import {FIleManagerTopNavbar} from "@/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx";
import {cn} from '@/lib/utils.ts';
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
import {Separator} from '@/components/ui/separator.tsx';
import {toast} from 'sonner';
import {useTranslation} from 'react-i18next';
import {
getFileManagerRecent,
getFileManagerPinned,
getFileManagerShortcuts,
addFileManagerRecent,
removeFileManagerRecent,
addFileManagerPinned,
removeFileManagerPinned,
addFileManagerShortcut,
removeFileManagerShortcut,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH
} from '@/ui/main-axios.ts';
interface Tab {
id: string | number;
title: string;
fileName: string;
content: string;
isSSH?: boolean;
sshSessionId?: string;
filePath?: string;
loading?: boolean;
dirty?: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
export function FileManager({onSelectView, embedded = false, initialHost = null}: {
onSelectView?: (view: string) => void,
embedded?: boolean,
initialHost?: SSHHost | null
}): React.ReactElement {
const {t} = useTranslation();
const [tabs, setTabs] = useState<Tab[]>([]);
const [activeTab, setActiveTab] = useState<string | number>('home');
const [recent, setRecent] = useState<any[]>([]);
const [pinned, setPinned] = useState<any[]>([]);
const [shortcuts, setShortcuts] = useState<any[]>([]);
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [showOperations, setShowOperations] = useState(false);
const [currentPath, setCurrentPath] = useState('/');
const [deletingItem, setDeletingItem] = useState<any | null>(null);
const sidebarRef = useRef<any>(null);
useEffect(() => {
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
setCurrentHost(initialHost);
setTimeout(() => {
try {
const path = initialHost.defaultPath || '/';
if (sidebarRef.current && sidebarRef.current.openFolder) {
sidebarRef.current.openFolder(initialHost, path);
}
} catch (e) {
}
}, 0);
}
}, [initialHost]);
useEffect(() => {
if (currentHost) {
fetchHomeData();
} else {
setRecent([]);
setPinned([]);
setShortcuts([]);
}
}, [currentHost]);
useEffect(() => {
if (activeTab === 'home' && currentHost) {
fetchHomeData();
}
}, [activeTab, currentHost]);
useEffect(() => {
if (activeTab === 'home' && currentHost) {
const interval = setInterval(() => {
fetchHomeData();
}, 2000);
return () => clearInterval(interval);
}
}, [activeTab, currentHost]);
async function fetchHomeData() {
if (!currentHost) return;
try {
const homeDataPromise = Promise.all([
getFileManagerRecent(currentHost.id),
getFileManagerPinned(currentHost.id),
getFileManagerShortcuts(currentHost.id),
]);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error(t('fileManager.fetchHomeDataTimeout'))), 15000)
);
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]) as [any, any, any];
const recentWithPinnedStatus = (recentRes || []).map(file => ({
...file,
type: 'file',
isPinned: (pinnedRes || []).some(pinnedFile =>
pinnedFile.path === file.path && pinnedFile.name === file.name
)
}));
const pinnedWithType = (pinnedRes || []).map(file => ({
...file,
type: 'file'
}));
setRecent(recentWithPinnedStatus);
setPinned(pinnedWithType);
setShortcuts((shortcutsRes || []).map(shortcut => ({
...shortcut,
type: 'directory'
})));
} catch (err: any) {
}
}
const formatErrorMessage = (err: any, defaultMessage: string): string => {
if (typeof err === 'object' && err !== null && 'response' in err) {
const axiosErr = err as any;
if (axiosErr.response?.status === 403) {
return `${t('fileManager.permissionDenied')}. ${defaultMessage}. ${t('fileManager.checkDockerLogs')}.`;
} else if (axiosErr.response?.status === 500) {
const backendError = axiosErr.response?.data?.error || t('fileManager.internalServerError');
return `${t('fileManager.serverError')} (500): ${backendError}. ${t('fileManager.checkDockerLogs')}.`;
} else if (axiosErr.response?.data?.error) {
const backendError = axiosErr.response.data.error;
return `${axiosErr.response?.status ? `${t('fileManager.error')} ${axiosErr.response.status}: ` : ''}${backendError}. ${t('fileManager.checkDockerLogs')}.`;
} else {
return `${t('fileManager.requestFailed')} ${axiosErr.response?.status || t('fileManager.unknown')}. ${t('fileManager.checkDockerLogs')}.`;
}
} else if (err instanceof Error) {
return `${err.message}. ${t('fileManager.checkDockerLogs')}.`;
} else {
return `${defaultMessage}. ${t('fileManager.checkDockerLogs')}.`;
}
};
const handleOpenFile = async (file: any) => {
const tabId = file.path;
if (!tabs.find(t => t.id === tabId)) {
const currentSshSessionId = currentHost?.id.toString();
setTabs([...tabs, {
id: tabId,
title: file.name,
fileName: file.name,
content: '',
filePath: file.path,
isSSH: true,
sshSessionId: currentSshSessionId,
loading: true
}]);
try {
const res = await readSSHFile(currentSshSessionId, file.path);
setTabs(tabs => tabs.map(t => t.id === tabId ? {
...t,
content: res.content,
loading: false,
error: undefined
} : t));
await addFileManagerRecent({
name: file.name,
path: file.path,
isSSH: true,
sshSessionId: currentSshSessionId,
hostId: currentHost?.id
});
fetchHomeData();
} catch (err: any) {
const errorMessage = formatErrorMessage(err, t('fileManager.cannotReadFile'));
toast.error(errorMessage);
setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false} : t));
}
}
setActiveTab(tabId);
};
const handleRemoveRecent = async (file: any) => {
try {
await removeFileManagerRecent({
name: file.name,
path: file.path,
isSSH: true,
sshSessionId: file.sshSessionId,
hostId: currentHost?.id
});
fetchHomeData();
} catch (err) {
}
};
const handlePinFile = async (file: any) => {
try {
await addFileManagerPinned({
name: file.name,
path: file.path,
isSSH: true,
sshSessionId: file.sshSessionId,
hostId: currentHost?.id
});
fetchHomeData();
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles();
}
} catch (err) {
}
};
const handleUnpinFile = async (file: any) => {
try {
await removeFileManagerPinned({
name: file.name,
path: file.path,
isSSH: true,
sshSessionId: file.sshSessionId,
hostId: currentHost?.id
});
fetchHomeData();
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles();
}
} catch (err) {
}
};
const handleOpenShortcut = async (shortcut: any) => {
if (sidebarRef.current?.isOpeningShortcut) {
return;
}
if (sidebarRef.current && sidebarRef.current.openFolder) {
try {
sidebarRef.current.isOpeningShortcut = true;
const normalizedPath = shortcut.path.startsWith('/') ? shortcut.path : `/${shortcut.path}`;
await sidebarRef.current.openFolder(currentHost, normalizedPath);
} catch (err) {
} finally {
if (sidebarRef.current) {
sidebarRef.current.isOpeningShortcut = false;
}
}
} else {
}
};
const handleAddShortcut = async (folderPath: string) => {
try {
const name = folderPath.split('/').pop() || folderPath;
await addFileManagerShortcut({
name,
path: folderPath,
isSSH: true,
sshSessionId: currentHost?.id.toString(),
hostId: currentHost?.id
});
fetchHomeData();
} catch (err) {
}
};
const handleRemoveShortcut = async (shortcut: any) => {
try {
await removeFileManagerShortcut({
name: shortcut.name,
path: shortcut.path,
isSSH: true,
sshSessionId: currentHost?.id.toString(),
hostId: currentHost?.id
});
fetchHomeData();
} catch (err) {
}
};
const closeTab = (tabId: string | number) => {
const idx = tabs.findIndex(t => t.id === tabId);
const newTabs = tabs.filter(t => t.id !== tabId);
setTabs(newTabs);
if (activeTab === tabId) {
if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id);
else setActiveTab('home');
}
if (currentHost) {
fetchHomeData();
}
};
const setTabContent = (tabId: string | number, content: string) => {
setTabs(tabs => tabs.map(t => t.id === tabId ? {
...t,
content,
dirty: true,
error: undefined,
success: undefined
} : t));
};
const handleSave = async (tab: Tab) => {
if (isSaving) {
return;
}
setIsSaving(true);
try {
if (!tab.sshSessionId) {
throw new Error(t('fileManager.noSshSessionId'));
}
if (!tab.filePath) {
throw new Error(t('fileManager.noFilePath'));
}
if (!currentHost?.id) {
throw new Error(t('fileManager.noCurrentHost'));
}
try {
const statusPromise = getSSHStatus(tab.sshSessionId);
const statusTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error(t('fileManager.sshStatusCheckTimeout'))), 10000)
);
const status = await Promise.race([statusPromise, statusTimeoutPromise]) as { connected: boolean };
if (!status.connected) {
const connectPromise = connectSSH(tab.sshSessionId, {
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
password: currentHost.password,
sshKey: currentHost.key,
keyPassword: currentHost.keyPassword
});
const connectTimeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error(t('fileManager.sshReconnectionTimeout'))), 15000)
);
await Promise.race([connectPromise, connectTimeoutPromise]);
}
} catch (statusErr) {
}
const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => {
reject(new Error(t('fileManager.saveOperationTimeout')));
}, 30000)
);
const result = await Promise.race([savePromise, timeoutPromise]);
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
loading: false
} : t));
toast.success(t('fileManager.fileSavedSuccessfully'));
Promise.allSettled([
(async () => {
try {
await addFileManagerRecent({
name: tab.fileName,
path: tab.filePath,
isSSH: true,
sshSessionId: tab.sshSessionId,
hostId: currentHost.id
});
} catch (recentErr) {
}
})(),
(async () => {
try {
await fetchHomeData();
} catch (refreshErr) {
}
})()
]).then(() => {
});
} catch (err) {
let errorMessage = formatErrorMessage(err, t('fileManager.cannotSaveFile'));
if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) {
errorMessage = t('fileManager.saveTimeout');
}
toast.error(`${t('fileManager.failedToSaveFile')}: ${errorMessage}`);
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
loading: false
} : t));
} finally {
setIsSaving(false);
}
};
const handleHostChange = (_host: SSHHost | null) => {
};
const handleOperationComplete = () => {
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
sidebarRef.current.fetchFiles();
}
if (currentHost) {
fetchHomeData();
}
};
const handleSuccess = (message: string) => {
toast.success(message);
};
const handleError = (error: string) => {
toast.error(error);
};
const updateCurrentPath = (newPath: string) => {
setCurrentPath(newPath);
};
const handleDeleteFromSidebar = (item: any) => {
setDeletingItem(item);
};
const performDelete = async (item: any) => {
if (!currentHost?.id) return;
try {
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
toast.success(`${item.type === 'directory' ? t('fileManager.folder') : t('fileManager.file')} ${t('fileManager.deletedSuccessfully')}`);
setDeletingItem(null);
handleOperationComplete();
} catch (error: any) {
handleError(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
}
};
if (!currentHost) {
return (
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
<FileManagerLeftSidebar
onSelectView={onSelectView || (() => {
})}
onOpenFile={handleOpenFile}
tabs={tabs}
ref={sidebarRef}
host={initialHost as SSHHost}
onOperationComplete={handleOperationComplete}
onError={handleError}
onSuccess={handleSuccess}
onPathChange={updateCurrentPath}
/>
</div>
<div style={{
position: 'absolute',
top: 0,
left: 256,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#09090b'
}}>
<div className="text-center">
<h2 className="text-xl font-semibold text-white mb-2">{t('fileManager.connectToServer')}</h2>
<p className="text-muted-foreground">{t('fileManager.selectServerToEdit')}</p>
</div>
</div>
</div>
);
}
return (
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
<FileManagerLeftSidebar
onSelectView={onSelectView || (() => {
})}
onOpenFile={handleOpenFile}
tabs={tabs}
ref={sidebarRef}
host={currentHost as SSHHost}
onOperationComplete={handleOperationComplete}
onError={handleError}
onSuccess={handleSuccess}
onPathChange={updateCurrentPath}
onDeleteItem={handleDeleteFromSidebar}
/>
</div>
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 50, zIndex: 30}}>
<div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-[50px] relative">
<div
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
<FIleManagerTopNavbar
tabs={tabs.map(t => ({id: t.id, title: t.title}))}
activeTab={activeTab}
setActiveTab={setActiveTab}
closeTab={closeTab}
onHomeClick={() => {
setActiveTab('home');
if (currentHost) {
fetchHomeData();
}
}}
/>
</div>
<div className="flex items-center justify-center gap-2 flex-1">
<Button
variant="outline"
onClick={() => setShowOperations(!showOperations)}
className={cn(
'w-[30px] h-[30px]',
showOperations ? 'bg-[#2d2d30] border-[#434345]' : ''
)}
title={t('fileManager.fileOperations')}
>
<Settings className="h-4 w-4"/>
</Button>
<div className="p-0.25 w-px h-[30px] bg-[#303032]"></div>
<Button
variant="outline"
onClick={() => {
const tab = tabs.find(t => t.id === activeTab);
if (tab && !isSaving) handleSave(tab);
}}
disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
className={cn(
'w-[30px] h-[30px]',
activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : ''
)}
>
{isSaving ? <RefreshCw className="h-4 w-4 animate-spin"/> : <Save className="h-4 w-4"/>}
</Button>
</div>
</div>
</div>
<div style={{
position: 'absolute',
top: 44,
left: 256,
right: 0,
bottom: 0,
overflow: 'hidden',
zIndex: 10,
background: '#101014',
display: 'flex',
flexDirection: 'column'
}}>
<div className="flex h-full">
<div className="flex-1">
{activeTab === 'home' ? (
<FileManagerHomeView
recent={recent}
pinned={pinned}
shortcuts={shortcuts}
onOpenFile={handleOpenFile}
onRemoveRecent={handleRemoveRecent}
onPinFile={handlePinFile}
onUnpinFile={handleUnpinFile}
onOpenShortcut={handleOpenShortcut}
onRemoveShortcut={handleRemoveShortcut}
onAddShortcut={handleAddShortcut}
/>
) : (
(() => {
const tab = tabs.find(t => t.id === activeTab);
if (!tab) return null;
return (
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
<div className="flex-1 min-h-0">
<FileManagerFileEditor
content={tab.content}
fileName={tab.fileName}
onContentChange={content => setTabContent(tab.id, content)}
/>
</div>
</div>
);
})()
)}
</div>
{showOperations && (
<div className="w-80 border-l-2 border-[#303032] bg-[#09090b] overflow-y-auto">
<FileManagerOperations
currentPath={currentPath}
sshSessionId={currentHost?.id.toString() || null}
onOperationComplete={handleOperationComplete}
onError={handleError}
onSuccess={handleSuccess}
/>
</div>
)}
</div>
</div>
{deletingItem && (
<div className="fixed inset-0 z-[99999]">
<div className="absolute inset-0 bg-black/60"></div>
<div className="relative h-full flex items-center justify-center">
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 max-w-md mx-4 shadow-2xl">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Trash2 className="w-5 h-5 text-red-400"/>
{t('fileManager.confirmDelete')}
</h3>
<p className="text-white mb-4">
{t('fileManager.confirmDeleteMessage', { name: deletingItem.name })}
{deletingItem.type === 'directory' && ` ${t('fileManager.deleteDirectoryWarning')}`}
</p>
<p className="text-red-400 text-sm mb-6">
{t('fileManager.actionCannotBeUndone')}
</p>
<div className="flex gap-3">
<Button
variant="destructive"
onClick={() => performDelete(deletingItem)}
className="flex-1"
>
{t('common.delete')}
</Button>
<Button
variant="outline"
onClick={() => setDeletingItem(null)}
className="flex-1"
>
{t('common.cancel')}
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,350 @@
import React, {useState, useEffect} from "react";
import CodeMirror from "@uiw/react-codemirror";
import {loadLanguage} from '@uiw/codemirror-extensions-langs';
import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
import {oneDark} from '@codemirror/theme-one-dark';
import {EditorView} from '@codemirror/view';
interface FileManagerCodeEditorProps {
content: string;
fileName: string;
onContentChange: (value: string) => void;
}
export function FileManagerFileEditor({content, fileName, onContentChange}: FileManagerCodeEditorProps) {
function getLanguageName(filename: string): string {
if (!filename || typeof filename !== 'string') {
return 'text';
}
const lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex === -1) {
return 'text';
}
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
switch (ext) {
case 'ng':
return 'angular';
case 'apl':
return 'apl';
case 'asc':
return 'asciiArmor';
case 'ast':
return 'asterisk';
case 'bf':
return 'brainfuck';
case 'c':
return 'c';
case 'ceylon':
return 'ceylon';
case 'clj':
return 'clojure';
case 'cmake':
return 'cmake';
case 'cob':
case 'cbl':
return 'cobol';
case 'coffee':
return 'coffeescript';
case 'lisp':
return 'commonLisp';
case 'cpp':
case 'cc':
case 'cxx':
return 'cpp';
case 'cr':
return 'crystal';
case 'cs':
return 'csharp';
case 'css':
return 'css';
case 'cypher':
return 'cypher';
case 'd':
return 'd';
case 'dart':
return 'dart';
case 'diff':
case 'patch':
return 'diff';
case 'dockerfile':
return 'dockerfile';
case 'dtd':
return 'dtd';
case 'dylan':
return 'dylan';
case 'ebnf':
return 'ebnf';
case 'ecl':
return 'ecl';
case 'eiffel':
return 'eiffel';
case 'elm':
return 'elm';
case 'erl':
return 'erlang';
case 'factor':
return 'factor';
case 'fcl':
return 'fcl';
case 'fs':
return 'forth';
case 'f90':
case 'for':
return 'fortran';
case 's':
return 'gas';
case 'feature':
return 'gherkin';
case 'go':
return 'go';
case 'groovy':
return 'groovy';
case 'hs':
return 'haskell';
case 'hx':
return 'haxe';
case 'html':
case 'htm':
return 'html';
case 'http':
return 'http';
case 'idl':
return 'idl';
case 'java':
return 'java';
case 'js':
case 'mjs':
case 'cjs':
return 'javascript';
case 'jinja2':
case 'j2':
return 'jinja2';
case 'json':
return 'json';
case 'jsx':
return 'jsx';
case 'jl':
return 'julia';
case 'kt':
case 'kts':
return 'kotlin';
case 'less':
return 'less';
case 'lezer':
return 'lezer';
case 'liquid':
return 'liquid';
case 'litcoffee':
return 'livescript';
case 'lua':
return 'lua';
case 'md':
return 'markdown';
case 'nb':
case 'mat':
return 'mathematica';
case 'mbox':
return 'mbox';
case 'mmd':
return 'mermaid';
case 'mrc':
return 'mirc';
case 'moo':
return 'modelica';
case 'mscgen':
return 'mscgen';
case 'm':
return 'mumps';
case 'sql':
return 'mysql';
case 'nc':
return 'nesC';
case 'nginx':
return 'nginx';
case 'nix':
return 'nix';
case 'nsi':
return 'nsis';
case 'nt':
return 'ntriples';
case 'mm':
return 'objectiveCpp';
case 'octave':
return 'octave';
case 'oz':
return 'oz';
case 'pas':
return 'pascal';
case 'pl':
case 'pm':
return 'perl';
case 'pgsql':
return 'pgsql';
case 'php':
return 'php';
case 'pig':
return 'pig';
case 'ps1':
return 'powershell';
case 'properties':
return 'properties';
case 'proto':
return 'protobuf';
case 'pp':
return 'puppet';
case 'py':
return 'python';
case 'q':
return 'q';
case 'r':
return 'r';
case 'rb':
return 'ruby';
case 'rs':
return 'rust';
case 'sas':
return 'sas';
case 'sass':
case 'scss':
return 'sass';
case 'scala':
return 'scala';
case 'scm':
return 'scheme';
case 'shader':
return 'shader';
case 'sh':
case 'bash':
return 'shell';
case 'siv':
return 'sieve';
case 'st':
return 'smalltalk';
case 'sol':
return 'solidity';
case 'solr':
return 'solr';
case 'rq':
return 'sparql';
case 'xlsx':
case 'ods':
case 'csv':
return 'spreadsheet';
case 'nut':
return 'squirrel';
case 'tex':
return 'stex';
case 'styl':
return 'stylus';
case 'svelte':
return 'svelte';
case 'swift':
return 'swift';
case 'tcl':
return 'tcl';
case 'textile':
return 'textile';
case 'tiddlywiki':
return 'tiddlyWiki';
case 'tiki':
return 'tiki';
case 'toml':
return 'toml';
case 'troff':
return 'troff';
case 'tsx':
return 'tsx';
case 'ttcn':
return 'ttcn';
case 'ttl':
case 'turtle':
return 'turtle';
case 'ts':
return 'typescript';
case 'vb':
return 'vb';
case 'vbs':
return 'vbscript';
case 'vm':
return 'velocity';
case 'v':
return 'verilog';
case 'vhd':
case 'vhdl':
return 'vhdl';
case 'vue':
return 'vue';
case 'wat':
return 'wast';
case 'webidl':
return 'webIDL';
case 'xq':
case 'xquery':
return 'xQuery';
case 'xml':
return 'xml';
case 'yacas':
return 'yacas';
case 'yaml':
case 'yml':
return 'yaml';
case 'z80':
return 'z80';
default:
return 'text';
}
}
useEffect(() => {
document.body.style.overflowX = 'hidden';
return () => {
document.body.style.overflowX = '';
};
}, []);
return (
<div style={{
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
<div
style={{
width: '100%',
height: '100%',
overflow: 'auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
className="config-codemirror-scroll-wrapper"
>
<CodeMirror
value={content}
extensions={[
loadLanguage(getLanguageName(fileName || 'untitled.txt') as any) || [],
hyperLink,
oneDark,
EditorView.theme({
'&': {
backgroundColor: '#09090b !important',
},
'.cm-gutters': {
backgroundColor: '#18181b !important',
},
})
]}
onChange={(value: any) => onContentChange(value)}
theme={undefined}
height="100%"
basicSetup={{lineNumbers: true}}
style={{minHeight: '100%', minWidth: '100%', flex: 1}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,210 @@
import React from 'react';
import {Button} from '@/components/ui/button.tsx';
import {Trash2, Folder, File, Plus, Pin} from 'lucide-react';
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx';
import {Input} from '@/components/ui/input.tsx';
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
interface FileItem {
name: string;
path: string;
isPinned?: boolean;
type: 'file' | 'directory';
sshSessionId?: string;
}
interface ShortcutItem {
name: string;
path: string;
}
interface FileManagerHomeViewProps {
recent: FileItem[];
pinned: FileItem[];
shortcuts: ShortcutItem[];
onOpenFile: (file: FileItem) => void;
onRemoveRecent: (file: FileItem) => void;
onPinFile: (file: FileItem) => void;
onUnpinFile: (file: FileItem) => void;
onOpenShortcut: (shortcut: ShortcutItem) => void;
onRemoveShortcut: (shortcut: ShortcutItem) => void;
onAddShortcut: (path: string) => void;
}
export function FileManagerHomeView({
recent,
pinned,
shortcuts,
onOpenFile,
onRemoveRecent,
onPinFile,
onUnpinFile,
onOpenShortcut,
onRemoveShortcut,
onAddShortcut
}: FileManagerHomeViewProps) {
const {t} = useTranslation();
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
const [newShortcut, setNewShortcut] = useState('');
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
<div key={file.path}
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenFile(file)}
>
{file.type === 'directory' ?
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>
}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
{file.name}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{onPin && (
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
onClick={onPin}
>
<Pin
className={`w-3 h-3 ${isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
</Button>
)}
{onRemove && (
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
onClick={onRemove}
>
<Trash2 className="w-3 h-3 text-red-500"/>
</Button>
)}
</div>
</div>
);
const renderShortcutCard = (shortcut: ShortcutItem) => (
<div key={shortcut.path}
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenShortcut(shortcut)}
>
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
{shortcut.path}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-[#23232a] hover:bg-[#2d2d30] rounded-md"
onClick={() => onRemoveShortcut(shortcut)}
>
<Trash2 className="w-3 h-3 text-red-500"/>
</Button>
</div>
</div>
);
return (
<div className="p-4 flex flex-col gap-4 h-full bg-[#09090b]">
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">{t('fileManager.recent')}</TabsTrigger>
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">{t('fileManager.pinned')}</TabsTrigger>
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">{t('fileManager.folderShortcuts')}</TabsTrigger>
</TabsList>
<TabsContent value="recent" className="mt-0">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">{t('fileManager.noRecentFiles')}</span>
</div>
) : recent.map((file) =>
renderFileCard(
file,
() => onRemoveRecent(file),
() => file.isPinned ? onUnpinFile(file) : onPinFile(file),
file.isPinned
)
)}
</div>
</TabsContent>
<TabsContent value="pinned" className="mt-0">
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">{t('fileManager.noPinnedFiles')}</span>
</div>
) : pinned.map((file) =>
renderFileCard(
file,
undefined,
() => onUnpinFile(file),
true
)
)}
</div>
</TabsContent>
<TabsContent value="shortcuts" className="mt-0">
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border-2 border-[#303032] rounded-lg">
<Input
placeholder={t('fileManager.enterFolderPath')}
value={newShortcut}
onChange={e => setNewShortcut(e.target.value)}
className="flex-1 bg-[#23232a] border-2 border-[#303032] text-white placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === 'Enter' && newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut('');
}
}}
/>
<Button
size="sm"
variant="ghost"
className="h-8 px-2 bg-[#23232a] border-2 !border-[#303032] hover:bg-[#2d2d30] rounded-md"
onClick={() => {
if (newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut('');
}
}}
>
<Plus className="w-3.5 h-3.5 mr-1"/>
{t('common.add')}
</Button>
</div>
<div
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{shortcuts.length === 0 ? (
<div className="flex items-center justify-center py-4 col-span-full">
<span className="text-sm text-muted-foreground">{t('fileManager.noShortcuts')}</span>
</div>
) : shortcuts.map((shortcut) =>
renderShortcutCard(shortcut)
)}
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,574 @@
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
import {Separator} from '@/components/ui/separator.tsx';
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin, MoreVertical, Trash2, Edit3} from 'lucide-react';
import {ScrollArea} from '@/components/ui/scroll-area.tsx';
import {cn} from '@/lib/utils.ts';
import {Input} from '@/components/ui/input.tsx';
import {Button} from '@/components/ui/button.tsx';
import {toast} from 'sonner';
import {useTranslation} from 'react-i18next';
import {
listSSHFiles,
renameSSHItem,
deleteSSHItem,
getFileManagerRecent,
getFileManagerPinned,
addFileManagerPinned,
removeFileManagerPinned,
readSSHFile,
getSSHStatus,
connectSSH
} from '@/ui/main-axios.ts';
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: {
onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void;
tabs: any[];
host: SSHHost;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
onPathChange?: (path: string) => void;
onDeleteItem?: (item: any) => void;
},
ref
) {
const {t} = useTranslation();
const [currentPath, setCurrentPath] = useState('/');
const [files, setFiles] = useState<any[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [fileSearch, setFileSearch] = useState('');
const [debouncedFileSearch, setDebouncedFileSearch] = useState('');
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200);
return () => clearTimeout(handler);
}, [fileSearch]);
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [filesLoading, setFilesLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [connectingSSH, setConnectingSSH] = useState(false);
const [connectionCache, setConnectionCache] = useState<Record<string, {
sessionId: string;
timestamp: number
}>>({});
const [fetchingFiles, setFetchingFiles] = useState(false);
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
x: number;
y: number;
item: any;
}>({
visible: false,
x: 0,
y: 0,
item: null
});
const [renamingItem, setRenamingItem] = useState<{
item: any;
newName: string;
} | null>(null);
useEffect(() => {
const nextPath = host?.defaultPath || '/';
setCurrentPath(nextPath);
onPathChange?.(nextPath);
(async () => {
await connectToSSH(host);
})();
}, [host?.id]);
async function connectToSSH(server: SSHHost): Promise<string | null> {
const sessionId = server.id.toString();
const cached = connectionCache[sessionId];
if (cached && Date.now() - cached.timestamp < 30000) {
setSshSessionId(cached.sessionId);
return cached.sessionId;
}
if (connectingSSH) {
return null;
}
setConnectingSSH(true);
try {
if (!server.password && !server.key) {
toast.error(t('common.noAuthCredentials'));
return null;
}
const connectionConfig = {
ip: server.ip,
port: server.port,
username: server.username,
password: server.password,
sshKey: server.key,
keyPassword: server.keyPassword,
};
await connectSSH(sessionId, connectionConfig);
setSshSessionId(sessionId);
setConnectionCache(prev => ({
...prev,
[sessionId]: {sessionId, timestamp: Date.now()}
}));
return sessionId;
} catch (err: any) {
toast.error(err?.response?.data?.error || t('fileManager.failedToConnectSSH'));
setSshSessionId(null);
return null;
} finally {
setConnectingSSH(false);
}
}
async function fetchFiles() {
if (fetchingFiles) {
return;
}
setFetchingFiles(true);
setFiles([]);
setFilesLoading(true);
try {
let pinnedFiles: any[] = [];
try {
if (host) {
pinnedFiles = await getFileManagerPinned(host.id);
}
} catch (err) {
}
if (host && sshSessionId) {
let res: any[] = [];
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw new Error(t('fileManager.failedToReconnectSSH'));
}
} else {
res = await listSSHFiles(sshSessionId, currentPath);
}
} catch (sessionErr) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw sessionErr;
}
}
const processedFiles = (res || []).map((f: any) => {
const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
return {
...f,
path: filePath,
isPinned,
isSSH: true,
sshSessionId: sshSessionId
};
});
setFiles(processedFiles);
}
} catch (err: any) {
setFiles([]);
toast.error(err?.response?.data?.error || err?.message || t('fileManager.failedToListFiles'));
} finally {
setFilesLoading(false);
setFetchingFiles(false);
}
}
useEffect(() => {
if (host && sshSessionId && !connectingSSH && !fetchingFiles) {
const timeoutId = setTimeout(() => {
fetchFiles();
}, 100);
return () => clearTimeout(timeoutId);
}
}, [currentPath, host, sshSessionId]);
useImperativeHandle(ref, () => ({
openFolder: async (_server: SSHHost, path: string) => {
if (connectingSSH || fetchingFiles) {
return;
}
if (currentPath === path) {
setTimeout(() => fetchFiles(), 100);
return;
}
setFetchingFiles(false);
setFilesLoading(false);
setFiles([]);
setCurrentPath(path);
onPathChange?.(path);
if (!sshSessionId) {
const sessionId = await connectToSSH(host);
if (sessionId) setSshSessionId(sessionId);
}
},
fetchFiles: () => {
if (host && sshSessionId) {
fetchFiles();
}
},
getCurrentPath: () => currentPath
}));
useEffect(() => {
if (pathInputRef.current) {
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
}
}, [currentPath]);
const filteredFiles = files.filter(file => {
const q = debouncedFileSearch.trim().toLowerCase();
if (!q) return true;
return file.name.toLowerCase().includes(q);
});
const handleContextMenu = (e: React.MouseEvent, item: any) => {
e.preventDefault();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const menuWidth = 160;
const menuHeight = 80;
let x = e.clientX;
let y = e.clientY;
if (x + menuWidth > viewportWidth) {
x = e.clientX - menuWidth;
}
if (y + menuHeight > viewportHeight) {
y = e.clientY - menuHeight;
}
if (x < 0) {
x = 0;
}
if (y < 0) {
y = 0;
}
setContextMenu({
visible: true,
x,
y,
item
});
};
const closeContextMenu = () => {
setContextMenu({ visible: false, x: 0, y: 0, item: null });
};
const handleRename = async (item: any, newName: string) => {
if (!sshSessionId || !newName.trim() || newName === item.name) {
setRenamingItem(null);
return;
}
try {
await renameSSHItem(sshSessionId, item.path, newName.trim());
toast.success(`${item.type === 'directory' ? t('common.folder') : t('common.file')} ${t('common.renamedSuccessfully')}`);
setRenamingItem(null);
if (onOperationComplete) {
onOperationComplete();
} else {
fetchFiles();
}
} catch (error: any) {
toast.error(error?.response?.data?.error || t('fileManager.failedToRenameItem'));
}
};
const handleDelete = async (item: any) => {
if (!sshSessionId) return;
try {
await deleteSSHItem(sshSessionId, item.path, item.type === 'directory');
toast.success(`${item.type === 'directory' ? t('common.folder') : t('common.file')} ${t('common.deletedSuccessfully')}`);
if (onOperationComplete) {
onOperationComplete();
} else {
fetchFiles();
}
} catch (error: any) {
toast.error(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
}
};
const startRename = (item: any) => {
setRenamingItem({ item, newName: item.name });
closeContextMenu();
};
const startDelete = (item: any) => {
onDeleteItem?.(item);
closeContextMenu();
};
useEffect(() => {
const handleClickOutside = () => closeContextMenu();
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}, []);
const handlePathChange = (newPath: string) => {
setCurrentPath(newPath);
onPathChange?.(newPath);
};
return (
<div className="flex flex-col h-full w-[256px]" style={{maxWidth: 256}}>
<div className="flex flex-col flex-grow min-h-0">
<div className="flex-1 w-full h-full flex flex-col bg-[#09090b] border-r-2 border-[#303032] overflow-hidden p-0 relative min-h-0">
{host && (
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}>
<Button
size="icon"
variant="outline"
className="h-9 w-9 bg-[#18181b] border-2 border-[#303032] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
onClick={() => {
let path = currentPath;
if (path && path !== '/' && path !== '') {
if (path.endsWith('/')) path = path.slice(0, -1);
const lastSlash = path.lastIndexOf('/');
if (lastSlash > 0) {
handlePathChange(path.slice(0, lastSlash));
} else {
handlePathChange('/');
}
} else {
handlePathChange('/');
}
}}
>
<ArrowUp className="w-4 h-4"/>
</Button>
<Input ref={pathInputRef} value={currentPath}
onChange={e => handlePathChange(e.target.value)}
className="flex-1 bg-[#18181b] border-2 border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
/>
</div>
<div className="px-2 py-2 border-b-1 border-[#303032] bg-[#18181b]">
<Input
placeholder={t('fileManager.searchFilesAndFolders')}
className="w-full h-7 text-sm bg-[#23232a] border-2 border-[#434345] text-white placeholder:text-muted-foreground rounded-md"
autoComplete="off"
value={fileSearch}
onChange={e => setFileSearch(e.target.value)}
/>
</div>
<div className="flex-1 min-h-0 w-full bg-[#09090b] border-t-1 border-[#303032]">
<ScrollArea className="h-full w-full bg-[#09090b]">
<div className="p-2 pb-0">
{connectingSSH || filesLoading ? (
<div className="text-xs text-muted-foreground">{t('common.loading')}</div>
) : filteredFiles.length === 0 ? (
<div className="text-xs text-muted-foreground">{t('fileManager.noFilesOrFoldersFound')}</div>
) : (
<div className="flex flex-col gap-1">
{filteredFiles.map((item: any) => {
const isOpen = (tabs || []).some((t: any) => t.id === item.path);
const isRenaming = renamingItem?.item?.path === item.path;
const isDeleting = false;
return (
<div
key={item.path}
className={cn(
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded group max-w-full relative",
isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
)}
style={{maxWidth: 220, marginBottom: 8}}
onContextMenu={(e) => !isOpen && handleContextMenu(e, item)}
>
{isRenaming ? (
<div className="flex items-center gap-2 flex-1 min-w-0">
{item.type === 'directory' ?
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
<Input
value={renamingItem.newName}
onChange={(e) => setRenamingItem(prev => prev ? {...prev, newName: e.target.value} : null)}
className="flex-1 h-6 text-sm bg-[#23232a] border border-[#434345] text-white"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRename(item, renamingItem.newName);
} else if (e.key === 'Escape') {
setRenamingItem(null);
}
}}
onBlur={() => handleRename(item, renamingItem.newName)}
/>
</div>
) : (
<>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => !isOpen && (item.type === 'directory' ? handlePathChange(item.path) : onOpenFile({
name: item.name,
path: item.path,
isSSH: item.isSSH,
sshSessionId: item.sshSessionId
}))}
>
{item.type === 'directory' ?
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0"/> :
<File className="w-4 h-4 text-muted-foreground flex-shrink-0"/>}
<span className="text-sm text-white truncate flex-1 min-w-0">{item.name}</span>
</div>
<div className="flex items-center gap-1">
{item.type === 'file' && (
<Button size="icon" variant="ghost" className="h-7 w-7"
disabled={isOpen}
onClick={async (e) => {
e.stopPropagation();
try {
if (item.isPinned) {
await removeFileManagerPinned({
name: item.name,
path: item.path,
hostId: host?.id,
isSSH: true,
sshSessionId: host?.id.toString()
});
setFiles(files.map(f =>
f.path === item.path ? { ...f, isPinned: false } : f
));
} else {
await addFileManagerPinned({
name: item.name,
path: item.path,
hostId: host?.id,
isSSH: true,
sshSessionId: host?.id.toString()
});
setFiles(files.map(f =>
f.path === item.path ? { ...f, isPinned: true } : f
));
}
} catch (err) {
}
}}
>
<Pin className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
</Button>
)}
{!isOpen && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleContextMenu(e, item);
}}
>
<MoreVertical className="w-4 h-4" />
</Button>
)}
</div>
</>
)}
</div>
);
})}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
)}
</div>
</div>
{contextMenu.visible && contextMenu.item && (
<div
className="fixed z-[99998] bg-[#18181b] border-2 border-[#303032] rounded-lg shadow-xl py-1 min-w-[160px]"
style={{
left: contextMenu.x,
top: contextMenu.y,
}}
>
<button
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-[#2d2d30] flex items-center gap-2"
onClick={() => startRename(contextMenu.item)}
>
<Edit3 className="w-4 h-4" />
Rename
</button>
<button
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-[#2d2d30] flex items-center gap-2"
onClick={() => startDelete(contextMenu.item)}
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
)}
</div>
);
});
export {FileManagerLeftSidebar};

View File

@@ -0,0 +1,110 @@
import React from 'react';
import {Button} from '@/components/ui/button.tsx';
import {Card} from '@/components/ui/card.tsx';
import {Separator} from '@/components/ui/separator.tsx';
import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react';
import {useTranslation} from 'react-i18next';
interface SSHConnection {
id: string;
name: string;
ip: string;
port: number;
username: string;
isPinned?: boolean;
}
interface FileItem {
name: string;
type: 'file' | 'directory' | 'link';
path: string;
isStarred?: boolean;
}
interface FileManagerLeftSidebarVileViewerProps {
sshConnections: SSHConnection[];
onAddSSH: () => void;
onConnectSSH: (conn: SSHConnection) => void;
onEditSSH: (conn: SSHConnection) => void;
onDeleteSSH: (conn: SSHConnection) => void;
onPinSSH: (conn: SSHConnection) => void;
currentPath: string;
files: FileItem[];
onOpenFile: (file: FileItem) => void;
onOpenFolder: (folder: FileItem) => void;
onStarFile: (file: FileItem) => void;
onDeleteFile: (file: FileItem) => void;
isLoading?: boolean;
error?: string;
isSSHMode: boolean;
onSwitchToLocal: () => void;
onSwitchToSSH: (conn: SSHConnection) => void;
currentSSH?: SSHConnection;
}
export function FileManagerLeftSidebarFileViewer({
sshConnections,
onAddSSH,
onConnectSSH,
onEditSSH,
onDeleteSSH,
onPinSSH,
currentPath,
files,
onOpenFile,
onOpenFolder,
onStarFile,
onDeleteFile,
isLoading,
error,
isSSHMode,
onSwitchToLocal,
onSwitchToSSH,
currentSSH,
}: FileManagerLeftSidebarVileViewerProps) {
const {t} = useTranslation();
return (
<div className="flex flex-col h-full">
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2">
<span
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? t('common.sshPath') : t('common.localPath')}</span>
<span className="text-xs text-white truncate">{currentPath}</span>
</div>
{isLoading ? (
<div className="text-xs text-muted-foreground">{t('common.loading')}</div>
) : error ? (
<div className="text-xs text-red-500">{error}</div>
) : (
<div className="flex flex-col gap-1">
{files.map((item) => (
<Card key={item.path}
className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border-2 border-[#303032] rounded">
<div className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> :
<File className="w-4 h-4 text-muted-foreground"/>}
<span className="text-sm text-white truncate">{item.name}</span>
</div>
<div className="flex items-center gap-1">
<Button size="icon" variant="ghost" className="h-7 w-7"
onClick={() => onStarFile(item)}>
<Pin
className={`w-4 h-4 ${item.isStarred ? 'text-yellow-400' : 'text-muted-foreground'}`}/>
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7"
onClick={() => onDeleteFile(item)}>
<Trash2 className="w-4 h-4 text-red-500"/>
</Button>
</div>
</Card>
))}
{files.length === 0 &&
<div className="text-xs text-muted-foreground">No files or folders found.</div>}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,626 @@
import React, {useState, useRef, useEffect} from 'react';
import {Button} from '@/components/ui/button.tsx';
import {Input} from '@/components/ui/input.tsx';
import {Card} from '@/components/ui/card.tsx';
import {Separator} from '@/components/ui/separator.tsx';
import {
Upload,
FilePlus,
FolderPlus,
Trash2,
Edit3,
X,
Check,
AlertCircle,
FileText,
Folder
} from 'lucide-react';
import {cn} from '@/lib/utils.ts';
import {useTranslation} from 'react-i18next';
interface FileManagerOperationsProps {
currentPath: string;
sshSessionId: string | null;
onOperationComplete: () => void;
onError: (error: string) => void;
onSuccess: (message: string) => void;
}
export function FileManagerOperations({
currentPath,
sshSessionId,
onOperationComplete,
onError,
onSuccess
}: FileManagerOperationsProps) {
const {t} = useTranslation();
const [showUpload, setShowUpload] = useState(false);
const [showCreateFile, setShowCreateFile] = useState(false);
const [showCreateFolder, setShowCreateFolder] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [showRename, setShowRename] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [newFileName, setNewFileName] = useState('');
const [newFolderName, setNewFolderName] = useState('');
const [deletePath, setDeletePath] = useState('');
const [deleteIsDirectory, setDeleteIsDirectory] = useState(false);
const [renamePath, setRenamePath] = useState('');
const [renameIsDirectory, setRenameIsDirectory] = useState(false);
const [newName, setNewName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showTextLabels, setShowTextLabels] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const checkContainerWidth = () => {
if (containerRef.current) {
const width = containerRef.current.offsetWidth;
setShowTextLabels(width > 240);
}
};
checkContainerWidth();
const resizeObserver = new ResizeObserver(checkContainerWidth);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, []);
const handleFileUpload = async () => {
if (!uploadFile || !sshSessionId) return;
setIsLoading(true);
try {
const content = await uploadFile.text();
const {uploadSSHFile} = await import('@/ui/main-axios.ts');
await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
onSuccess(t('fileManager.fileUploadedSuccessfully', { name: uploadFile.name }));
setShowUpload(false);
setUploadFile(null);
onOperationComplete();
} catch (error: any) {
onError(error?.response?.data?.error || t('fileManager.failedToUploadFile'));
} finally {
setIsLoading(false);
}
};
const handleCreateFile = async () => {
if (!newFileName.trim() || !sshSessionId) return;
setIsLoading(true);
try {
const {createSSHFile} = await import('@/ui/main-axios.ts');
await createSSHFile(sshSessionId, currentPath, newFileName.trim());
onSuccess(t('fileManager.fileCreatedSuccessfully', { name: newFileName.trim() }));
setShowCreateFile(false);
setNewFileName('');
onOperationComplete();
} catch (error: any) {
onError(error?.response?.data?.error || t('fileManager.failedToCreateFile'));
} finally {
setIsLoading(false);
}
};
const handleCreateFolder = async () => {
if (!newFolderName.trim() || !sshSessionId) return;
setIsLoading(true);
try {
const {createSSHFolder} = await import('@/ui/main-axios.ts');
await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
onSuccess(t('fileManager.folderCreatedSuccessfully', { name: newFolderName.trim() }));
setShowCreateFolder(false);
setNewFolderName('');
onOperationComplete();
} catch (error: any) {
onError(error?.response?.data?.error || t('fileManager.failedToCreateFolder'));
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!deletePath || !sshSessionId) return;
setIsLoading(true);
try {
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
onSuccess(t('fileManager.itemDeletedSuccessfully', { type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
setShowDelete(false);
setDeletePath('');
setDeleteIsDirectory(false);
onOperationComplete();
} catch (error: any) {
onError(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
} finally {
setIsLoading(false);
}
};
const handleRename = async () => {
if (!renamePath || !newName.trim() || !sshSessionId) return;
setIsLoading(true);
try {
const {renameSSHItem} = await import('@/ui/main-axios.ts');
await renameSSHItem(sshSessionId, renamePath, newName.trim());
onSuccess(t('fileManager.itemRenamedSuccessfully', { type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
setShowRename(false);
setRenamePath('');
setRenameIsDirectory(false);
setNewName('');
onOperationComplete();
} catch (error: any) {
onError(error?.response?.data?.error || t('fileManager.failedToRenameItem'));
} finally {
setIsLoading(false);
}
};
const openFileDialog = () => {
fileInputRef.current?.click();
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setUploadFile(file);
}
};
const resetStates = () => {
setShowUpload(false);
setShowCreateFile(false);
setShowCreateFolder(false);
setShowDelete(false);
setShowRename(false);
setUploadFile(null);
setNewFileName('');
setNewFolderName('');
setDeletePath('');
setDeleteIsDirectory(false);
setRenamePath('');
setRenameIsDirectory(false);
setNewName('');
};
if (!sshSessionId) {
return (
<div className="p-4 text-center">
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2"/>
<p className="text-sm text-muted-foreground">{t('fileManager.connectToSsh')}</p>
</div>
);
}
return (
<div ref={containerRef} className="p-4 space-y-4">
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowUpload(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
title={t('fileManager.uploadFile')}
>
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
{showTextLabels && <span className="truncate">{t('fileManager.uploadFile')}</span>}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateFile(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
title={t('fileManager.newFile')}
>
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
{showTextLabels && <span className="truncate">{t('fileManager.newFile')}</span>}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateFolder(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
title={t('fileManager.newFolder')}
>
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
{showTextLabels && <span className="truncate">{t('fileManager.newFolder')}</span>}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowRename(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
title={t('fileManager.rename')}
>
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
{showTextLabels && <span className="truncate">{t('fileManager.rename')}</span>}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDelete(true)}
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
title={t('fileManager.deleteItem')}
>
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
{showTextLabels && <span className="truncate">{t('fileManager.deleteItem')}</span>}
</Button>
</div>
<div className="bg-[#141416] border-2 border-[#373739] rounded-md p-3">
<div className="flex items-start gap-2 text-sm">
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5"/>
<div className="flex-1 min-w-0">
<span className="text-muted-foreground block mb-1">{t('fileManager.currentPath')}:</span>
<span className="text-white font-mono text-xs break-all leading-relaxed">{currentPath}</span>
</div>
</div>
</div>
<Separator className="p-0.25 bg-[#303032]"/>
{showUpload && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
<span className="break-words">{t('fileManager.uploadFileTitle')}</span>
</h3>
<p className="text-xs text-muted-foreground break-words">
{t('fileManager.maxFileSize')}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowUpload(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4"/>
</Button>
</div>
<div className="space-y-3">
<div className="border-2 border-dashed border-[#434345] rounded-lg p-4 text-center">
{uploadFile ? (
<div className="space-y-3">
<FileText className="w-12 h-12 text-blue-400 mx-auto"/>
<p className="text-white font-medium text-sm break-words px-2">{uploadFile.name}</p>
<p className="text-xs text-muted-foreground">
{(uploadFile.size / 1024).toFixed(2)} KB
</p>
<Button
variant="outline"
size="sm"
onClick={() => setUploadFile(null)}
className="w-full text-sm h-8"
>
{t('fileManager.removeFile')}
</Button>
</div>
) : (
<div className="space-y-3">
<Upload className="w-12 h-12 text-muted-foreground mx-auto"/>
<p className="text-white text-sm break-words px-2">{t('fileManager.clickToSelectFile')}</p>
<Button
variant="outline"
size="sm"
onClick={openFileDialog}
className="w-full text-sm h-8"
>
{t('fileManager.chooseFile')}
</Button>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFileSelect}
className="hidden"
accept="*/*"
/>
<div className="flex flex-col gap-2">
<Button
onClick={handleFileUpload}
disabled={!uploadFile || isLoading}
className="w-full text-sm h-9"
>
{isLoading ? t('fileManager.uploading') : t('fileManager.uploadFile')}
</Button>
<Button
variant="outline"
onClick={() => setShowUpload(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t('common.cancel')}
</Button>
</div>
</div>
</Card>
)}
{showCreateFile && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
<span className="break-words">{t('fileManager.createNewFile')}</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateFile(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4"/>
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t('fileManager.fileName')}
</label>
<Input
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder={t('placeholders.fileName')}
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
/>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleCreateFile}
disabled={!newFileName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading ? t('fileManager.creating') : t('fileManager.createFile')}
</Button>
<Button
variant="outline"
onClick={() => setShowCreateFile(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t('common.cancel')}
</Button>
</div>
</div>
</Card>
)}
{showCreateFolder && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<FolderPlus className="w-6 h-6 flex-shrink-0"/>
<span className="break-words">{t('fileManager.createNewFolder')}</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateFolder(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4"/>
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t('fileManager.folderName')}
</label>
<Input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder={t('placeholders.folderName')}
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
/>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleCreateFolder}
disabled={!newFolderName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading ? t('fileManager.creating') : t('fileManager.createFolder')}
</Button>
<Button
variant="outline"
onClick={() => setShowCreateFolder(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t('common.cancel')}
</Button>
</div>
</div>
</Card>
)}
{showDelete && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0"/>
<span className="break-words">{t('fileManager.deleteItem')}</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDelete(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4"/>
</Button>
</div>
<div className="space-y-3">
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
<div className="flex items-start gap-2 text-red-300">
<AlertCircle className="w-5 h-5 flex-shrink-0"/>
<span className="text-sm font-medium break-words">{t('fileManager.warningCannotUndo')}</span>
</div>
</div>
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t('fileManager.itemPath')}
</label>
<Input
value={deletePath}
onChange={(e) => setDeletePath(e.target.value)}
placeholder={t('placeholders.fullPath')}
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
/>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="deleteIsDirectory"
checked={deleteIsDirectory}
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
/>
<label htmlFor="deleteIsDirectory" className="text-sm text-white break-words">
{t('fileManager.thisIsDirectory')}
</label>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleDelete}
disabled={!deletePath || isLoading}
variant="destructive"
className="w-full text-sm h-9"
>
{isLoading ? t('fileManager.deleting') : t('fileManager.deleteItem')}
</Button>
<Button
variant="outline"
onClick={() => setShowDelete(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t('common.cancel')}
</Button>
</div>
</div>
</Card>
)}
{showRename && (
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Edit3 className="w-6 h-6 flex-shrink-0"/>
<span className="break-words">{t('fileManager.renameItem')}</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowRename(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4"/>
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t('fileManager.currentPathLabel')}
</label>
<Input
value={renamePath}
onChange={(e) => setRenamePath(e.target.value)}
placeholder={t('placeholders.currentPath')}
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
/>
</div>
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t('fileManager.newName')}
</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('placeholders.newName')}
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
/>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="renameIsDirectory"
checked={renameIsDirectory}
onChange={(e) => setRenameIsDirectory(e.target.checked)}
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
/>
<label htmlFor="renameIsDirectory" className="text-sm text-white break-words">
{t('fileManager.thisIsDirectoryRename')}
</label>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleRename}
disabled={!renamePath || !newName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading ? t('fileManager.renaming') : t('fileManager.renameItem')}
</Button>
<Button
variant="outline"
onClick={() => setShowRename(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t('common.cancel')}
</Button>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import React from 'react';
import {Button} from '@/components/ui/button.tsx';
import {X, Home} from 'lucide-react';
interface FileManagerTab {
id: string | number;
title: string;
}
interface FileManagerTabList {
tabs: FileManagerTab[];
activeTab: string | number;
setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void;
onHomeClick: () => void;
}
export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: FileManagerTabList) {
return (
<div className="inline-flex items-center h-full gap-2">
<Button
onClick={onHomeClick}
variant="outline"
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-[#303032] ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
<Home className="w-4 h-4"/>
</Button>
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<div key={tab.id} className="inline-flex rounded-md shadow-sm" role="group">
<Button
onClick={() => setActiveTab(tab.id)}
variant="outline"
className={`h-8 rounded-r-none !px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
>
{tab.title}
</Button>
<Button
onClick={() => closeTab(tab.id)}
variant="outline"
className="h-8 rounded-l-none p-0 !w-9 border-1 border-[#303032]"
>
<X className="!w-4 !h-4" strokeWidth={2}/>
</Button>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,103 @@
import React, {useState} from "react";
import {HostManagerHostViewer} from "@/ui/Desktop/Apps/Host Manager/HostManagerHostViewer.tsx"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {HostManagerHostEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerHostEditor.tsx";
import {useSidebar} from "@/components/ui/sidebar.tsx";
import {useTranslation} from "react-i18next";
interface HostManagerProps {
onSelectView: (view: string) => void;
isTopbarOpen?: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
const {t} = useTranslation();
const [activeTab, setActiveTab] = useState("host_viewer");
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
const {state: sidebarState} = useSidebar();
const handleEditHost = (host: SSHHost) => {
setEditingHost(host);
setActiveTab("add_host");
};
const handleFormSubmit = () => {
setEditingHost(null);
setActiveTab("host_viewer");
};
const handleTabChange = (value: string) => {
setActiveTab(value);
if (value === "host_viewer") {
setEditingHost(null);
}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const bottomMarginPx = 8;
return (
<div>
<div className="w-full">
<div
className="bg-[#18181b] text-white p-4 pt-0 rounded-lg border-2 border-[#303032] flex flex-col min-h-0 overflow-hidden"
style={{
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
}}
>
<Tabs value={activeTab} onValueChange={handleTabChange}
className="flex-1 flex flex-col h-full min-h-0">
<TabsList className="bg-[#18181b] border-2 border-[#303032] mt-1.5">
<TabsTrigger value="host_viewer">{t('hosts.hostViewer')}</TabsTrigger>
<TabsTrigger value="add_host">
{editingHost ? t('hosts.editHost') : t('hosts.addHost')}
</TabsTrigger>
</TabsList>
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 -mt-0.5 mb-1"/>
<HostManagerHostViewer onEditHost={handleEditHost}/>
</TabsContent>
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 -mt-0.5 mb-1"/>
<div className="flex flex-col h-full min-h-0">
<HostManagerHostEditor
editingHost={editingHost}
onFormSubmit={handleFormSubmit}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,489 @@
import React, {useState, useEffect, useMemo} from "react";
import {Card, CardContent} from "@/components/ui/card.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Badge} from "@/components/ui/badge.tsx";
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx";
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts";
import {toast} from "sonner";
import {useTranslation} from "react-i18next";
import {
Edit,
Trash2,
Server,
Folder,
Tag,
Pin,
Terminal,
Network,
FileEdit,
Search,
Upload,
Info
} from "lucide-react";
import {Separator} from "@/components/ui/separator.tsx";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface SSHManagerHostViewerProps {
onEditHost?: (host: SSHHost) => void;
}
export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
const {t} = useTranslation();
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [importing, setImporting] = useState(false);
useEffect(() => {
fetchHosts();
}, []);
const fetchHosts = async () => {
try {
setLoading(true);
const data = await getSSHHosts();
setHosts(data);
setError(null);
} catch (err) {
setError(t('hosts.failedToLoadHosts'));
} finally {
setLoading(false);
}
};
const handleDelete = async (hostId: number, hostName: string) => {
if (window.confirm(t('hosts.confirmDelete', { name: hostName }))) {
try {
await deleteSSHHost(hostId);
toast.success(t('hosts.hostDeletedSuccessfully', { name: hostName }));
await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (err) {
toast.error(t('hosts.failedToDeleteHost'));
}
}
};
const handleEdit = (host: SSHHost) => {
if (onEditHost) {
onEditHost(host);
}
};
const handleJsonImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
setImporting(true);
const text = await file.text();
const data = JSON.parse(text);
if (!Array.isArray(data.hosts) && !Array.isArray(data)) {
throw new Error(t('hosts.jsonMustContainHosts'));
}
const hostsArray = Array.isArray(data.hosts) ? data.hosts : data;
if (hostsArray.length === 0) {
throw new Error(t('hosts.noHostsInJson'));
}
if (hostsArray.length > 100) {
throw new Error(t('hosts.maxHostsAllowed'));
}
const result = await bulkImportSSHHosts(hostsArray);
if (result.success > 0) {
toast.success(t('hosts.importCompleted', { success: result.success, failed: result.failed }));
if (result.errors.length > 0) {
toast.error(`Import errors: ${result.errors.join(', ')}`);
}
await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} else {
toast.error(t('hosts.importFailed') + `: ${result.errors.join(', ')}`);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : t('hosts.failedToImportJson');
toast.error(t('hosts.importError') + `: ${errorMessage}`);
} finally {
setImporting(false);
event.target.value = '';
}
};
const filteredAndSortedHosts = useMemo(() => {
let filtered = hosts;
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = hosts.filter(host => {
const searchableText = [
host.name || '',
host.username,
host.ip,
host.folder || '',
...(host.tags || []),
host.authType,
host.defaultPath || ''
].join(' ').toLowerCase();
return searchableText.includes(query);
});
}
return filtered.sort((a, b) => {
if (a.pin && !b.pin) return -1;
if (!a.pin && b.pin) return 1;
const aName = a.name || a.username;
const bName = b.name || b.username;
return aName.localeCompare(bName);
});
}, [hosts, searchQuery]);
const hostsByFolder = useMemo(() => {
const grouped: { [key: string]: SSHHost[] } = {};
filteredAndSortedHosts.forEach(host => {
const folder = host.folder || t('hosts.uncategorized');
if (!grouped[folder]) {
grouped[folder] = [];
}
grouped[folder].push(host);
});
const sortedFolders = Object.keys(grouped).sort((a, b) => {
if (a === t('hosts.uncategorized')) return -1;
if (b === t('hosts.uncategorized')) return 1;
return a.localeCompare(b);
});
const sortedGrouped: { [key: string]: SSHHost[] } = {};
sortedFolders.forEach(folder => {
sortedGrouped[folder] = grouped[folder];
});
return sortedGrouped;
}, [filteredAndSortedHosts]);
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">{t('hosts.loadingHosts')}</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={fetchHosts} variant="outline">
{t('hosts.retry')}
</Button>
</div>
</div>
);
}
if (hosts.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
<h3 className="text-lg font-semibold mb-2">{t('hosts.noHosts')}</h3>
<p className="text-muted-foreground mb-4">
{t('hosts.noHostsMessage')}
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2">
<div>
<h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2>
<p className="text-muted-foreground">
{t('hosts.hostsCount', { count: filteredAndSortedHosts.length })}
</p>
</div>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="relative"
onClick={() => document.getElementById('json-import-input')?.click()}
disabled={importing}
>
{importing ? t('hosts.importing') : t('hosts.importJson')}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom"
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg">
<div className="space-y-2">
<p className="font-semibold text-sm">{t('hosts.importJsonTitle')}</p>
<p className="text-xs text-muted-foreground">
{t('hosts.importJsonDesc')}
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
onClick={() => {
const sampleData = {
hosts: [
{
name: "Web Server - Production",
ip: "192.168.1.100",
port: 22,
username: "admin",
authType: "password",
password: "your_secure_password_here",
folder: "Production",
tags: ["web", "production", "nginx"],
pin: true,
enableTerminal: true,
enableTunnel: false,
enableFileManager: true,
defaultPath: "/var/www"
},
{
name: "Database Server",
ip: "192.168.1.101",
port: 22,
username: "dbadmin",
authType: "key",
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
keyPassword: "optional_key_passphrase",
keyType: "ssh-ed25519",
folder: "Production",
tags: ["database", "production", "postgresql"],
pin: false,
enableTerminal: true,
enableTunnel: true,
enableFileManager: false,
tunnelConnections: [
{
sourcePort: 5432,
endpointPort: 5432,
endpointHost: "Web Server - Production",
maxRetries: 3,
retryInterval: 10,
autoStart: true
}
]
}
]
};
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sample-ssh-hosts.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}}
>
{t('hosts.downloadSample')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
window.open('https://docs.termix.site/json-import', '_blank');
}}
>
{t('hosts.formatGuide')}
</Button>
<div className="w-px h-6 bg-border mx-2"/>
<Button onClick={fetchHosts} variant="outline" size="sm">
{t('hosts.refresh')}
</Button>
</div>
</div>
<input
id="json-import-input"
type="file"
accept=".json"
onChange={handleJsonImport}
style={{display: 'none'}}
/>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input
placeholder={t('placeholders.searchHosts')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-2 pb-20">
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
<div key={folder} className="border rounded-md">
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
<AccordionItem value={folder} className="border-none">
<AccordionTrigger
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
<div className="flex items-center gap-2">
<Folder className="h-4 w-4"/>
<span className="font-medium">{folder}</span>
<Badge variant="secondary" className="text-xs">
{folderHosts.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent className="p-2">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{folderHosts.map((host) => (
<div
key={host.id}
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
onClick={() => handleEdit(host)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
{host.pin && <Pin
className="h-3 w-3 text-yellow-500 flex-shrink-0"/>}
<h3 className="font-medium truncate text-sm">
{host.name || `${host.username}@${host.ip}`}
</h3>
</div>
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port}
</p>
<p className="text-xs text-muted-foreground truncate">
{host.username}
</p>
</div>
<div className="flex gap-1 flex-shrink-0 ml-1">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleEdit(host);
}}
className="h-5 w-5 p-0"
>
<Edit className="h-3 w-3"/>
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
}}
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
>
<Trash2 className="h-3 w-3"/>
</Button>
</div>
</div>
<div className="mt-2 space-y-1">
{host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{host.tags.slice(0, 6).map((tag, index) => (
<Badge key={index} variant="outline"
className="text-xs px-1 py-0">
<Tag className="h-2 w-2 mr-0.5"/>
{tag}
</Badge>
))}
{host.tags.length > 6 && (
<Badge variant="outline"
className="text-xs px-1 py-0">
+{host.tags.length - 6}
</Badge>
)}
</div>
)}
<div className="flex flex-wrap gap-1">
{host.enableTerminal && (
<Badge variant="outline" className="text-xs px-1 py-0">
<Terminal className="h-2 w-2 mr-0.5"/>
{t('hosts.terminalBadge')}
</Badge>
)}
{host.enableTunnel && (
<Badge variant="outline" className="text-xs px-1 py-0">
<Network className="h-2 w-2 mr-0.5"/>
{t('hosts.tunnelBadge')}
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
<span
className="ml-0.5">({host.tunnelConnections.length})</span>
)}
</Badge>
)}
{host.enableFileManager && (
<Badge variant="outline" className="text-xs px-1 py-0">
<FileEdit className="h-2 w-2 mr-0.5"/>
{t('hosts.fileManagerBadge')}
</Badge>
)}
</div>
</div>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,289 @@
import React from "react";
import {useSidebar} from "@/components/ui/sidebar.tsx";
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
import {Separator} from "@/components/ui/separator.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Progress} from "@/components/ui/progress.tsx"
import {Cpu, HardDrive, MemoryStick} from "lucide-react";
import {Tunnel} from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts";
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {useTranslation} from 'react-i18next';
interface ServerProps {
hostConfig?: any;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
export function Server({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false
}: ServerProps): React.ReactElement {
const {t} = useTranslation();
const {state: sidebarState} = useSidebar();
const {addTab, tabs} = useTabs() as any;
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
React.useEffect(() => {
setCurrentHostConfig(hostConfig);
}, [hostConfig]);
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const {getSSHHosts} = await import('@/ui/main-axios.ts');
const hosts = await getSSHHosts();
const updatedHost = hosts.find(h => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch (error) {
}
}
};
fetchLatestHostConfig();
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const {getSSHHosts} = await import('@/ui/main-axios.ts');
const hosts = await getSSHHosts();
const updatedHost = hosts.find(h => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch (error) {
}
}
};
window.addEventListener('ssh-hosts:changed', handleHostsChanged);
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged);
}, [hostConfig?.id]);
React.useEffect(() => {
let cancelled = false;
let intervalId: number | undefined;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(currentHostConfig?.id);
if (!cancelled) {
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
}
} catch {
if (!cancelled) setServerStatus('offline');
}
};
const fetchMetrics = async () => {
if (!currentHostConfig?.id) return;
try {
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) setMetrics(data);
} catch {
if (!cancelled) setMetrics(null);
}
};
if (currentHostConfig?.id && isVisible) {
fetchStatus();
fetchMetrics();
intervalId = window.setInterval(() => {
if (isVisible) {
fetchStatus();
fetchMetrics();
}
}, 30000);
}
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [currentHostConfig?.id, isVisible]);
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
const bottomMarginPx = 8;
const isFileManagerAlreadyOpen = React.useMemo(() => {
if (!currentHostConfig) return false;
return tabs.some((tab: any) =>
tab.type === 'file_manager' &&
tab.hostConfig?.id === currentHostConfig.id
);
}, [tabs, currentHostConfig]);
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-white overflow-hidden bg-transparent"
: "bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden";
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
{/* Top Header */}
<div className="flex items-center justify-between px-3 pt-2 pb-2">
<div className="flex items-center gap-4">
<h1 className="font-bold text-lg">
{currentHostConfig?.folder} / {title}
</h1>
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
<StatusIndicator/>
</Status>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={async () => {
if (currentHostConfig?.id) {
try {
const res = await getServerStatusById(currentHostConfig.id);
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
const data = await getServerMetricsById(currentHostConfig.id);
setMetrics(data);
} catch {
setServerStatus('offline');
setMetrics(null);
}
}
}}
title={t('serverStats.refreshStatusAndMetrics')}
>
{t('serverStats.refreshStatus')}
</Button>
{currentHostConfig?.enableFileManager && (
<Button
variant="outline"
className="font-semibold"
disabled={isFileManagerAlreadyOpen}
title={isFileManagerAlreadyOpen ? t('serverStats.fileManagerAlreadyOpen') : t('serverStats.openFileManager')}
onClick={() => {
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
? currentHostConfig.name.trim()
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
addTab({
type: 'file_manager',
title: titleBase,
hostConfig: currentHostConfig,
});
}}
>
{t('nav.fileManager')}
</Button>
)}
</div>
</div>
<Separator className="p-0.25 w-full"/>
{/* Stats */}
<div className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] flex flex-row items-stretch">
{/* CPU */}
<div className="flex-1 min-w-0 px-2 py-2">
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
<Cpu/>
{(() => {
const pct = metrics?.cpu?.percent;
const cores = metrics?.cpu?.cores;
const la = metrics?.cpu?.load;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const coresText = (typeof cores === 'number') ? t('serverStats.cpuCores', {count: cores}) : t('serverStats.naCpus');
const laText = (la && la.length === 3)
? t('serverStats.loadAverage', {avg1: la[0].toFixed(2), avg5: la[1].toFixed(2), avg15: la[2].toFixed(2)})
: t('serverStats.loadAverageNA');
return `${t('serverStats.cpuUsage')} - ${pctText} ${t('serverStats.of')} ${coresText} (${laText})`;
})()}
</h1>
<Progress value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}/>
</div>
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
{/* Memory */}
<div className="flex-1 min-w-0 px-2 py-2">
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
<MemoryStick/>
{(() => {
const pct = metrics?.memory?.percent;
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
return `${t('serverStats.memoryUsage')} - ${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
})()}
</h1>
<Progress value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}/>
</div>
<Separator className="p-0.5 self-stretch" orientation="vertical"/>
{/* Root Storage */}
<div className="flex-1 min-w-0 px-2 py-2">
<h1 className="font-bold xt-lg flex flex-row gap-2 mb-2">
<HardDrive/>
{(() => {
const pct = metrics?.disk?.percent;
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
const usedText = used ?? 'N/A';
const totalText = total ?? 'N/A';
return `${t('serverStats.rootStorageSpace')} - ${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
})()}
</h1>
<Progress value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}/>
</div>
</div>
{/* SSH Tunnels */}
{(currentHostConfig?.tunnelConnections && currentHostConfig.tunnelConnections.length > 0) && (
<div
className="rounded-lg border-2 border-[#303032] m-3 bg-[#0e0e10] h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={(currentHostConfig?.name && currentHostConfig.name.trim() !== '') ? currentHostConfig.name : `${currentHostConfig?.username}@${currentHostConfig?.ip}`}/>
</div>
)}
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
{t('serverStats.feedbackMessage')}{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,418 @@
import {useEffect, useRef, useState, useImperativeHandle, forwardRef} from 'react';
import {useXTerm} from 'react-xtermjs';
import {FitAddon} from '@xterm/addon-fit';
import {ClipboardAddon} from '@xterm/addon-clipboard';
import {Unicode11Addon} from '@xterm/addon-unicode11';
import {WebLinksAddon} from '@xterm/addon-web-links';
import {useTranslation} from 'react-i18next';
interface SSHTerminalProps {
hostConfig: any;
isVisible: boolean;
title?: string;
showTitle?: boolean;
splitScreen?: boolean;
}
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{hostConfig, isVisible, splitScreen = false},
ref
) {
const {t} = useTranslation();
const {instance: terminal, ref: xtermRef} = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null);
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
const wasDisconnectedBySSH = useRef(false);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [visible, setVisible] = useState(false);
const isVisibleRef = useRef<boolean>(false);
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
const DEBOUNCE_MS = 140;
useEffect(() => {
isVisibleRef.current = isVisible;
}, [isVisible]);
function hardRefresh() {
try {
if (terminal && typeof (terminal as any).refresh === 'function') {
(terminal as any).refresh(0, terminal.rows - 1);
}
} catch (_) {
}
}
function scheduleNotify(cols: number, rows: number) {
if (!(cols > 0 && rows > 0)) return;
pendingSizeRef.current = {cols, rows};
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
notifyTimerRef.current = setTimeout(() => {
const next = pendingSizeRef.current;
const last = lastSentSizeRef.current;
if (!next) return;
if (last && last.cols === next.cols && last.rows === next.rows) return;
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(JSON.stringify({type: 'resize', data: next}));
lastSentSizeRef.current = next;
}
}, DEBOUNCE_MS);
}
useImperativeHandle(ref, () => ({
disconnect: () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
webSocketRef.current?.close();
},
fit: () => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
},
sendInput: (data: string) => {
if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(JSON.stringify({type: 'input', data}));
}
},
notifyResize: () => {
try {
const cols = terminal?.cols ?? undefined;
const rows = terminal?.rows ?? undefined;
if (typeof cols === 'number' && typeof rows === 'number') {
scheduleNotify(cols, rows);
hardRefresh();
}
} catch (_) {
}
},
refresh: () => hardRefresh(),
}), [terminal]);
useEffect(() => {
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
function handleWindowResize() {
if (!isVisibleRef.current) return;
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
function getUseRightClickCopyPaste() {
return getCookie("rightClickCopyPaste") === "true"
}
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
ws.addEventListener('open', () => {
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
terminal.onData((data) => {
ws.send(JSON.stringify({type: 'input', data}));
});
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({type: 'ping'}));
}
}, 30000);
});
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'data') terminal.write(msg.data);
else if (msg.type === 'error') terminal.writeln(`\r\n[${t('terminal.error')}] ${msg.message}`);
else if (msg.type === 'connected') {
} else if (msg.type === 'disconnected') {
wasDisconnectedBySSH.current = true;
terminal.writeln(`\r\n[${msg.message || t('terminal.disconnected')}]`);
}
} catch (error) {
}
});
ws.addEventListener('close', () => {
if (!wasDisconnectedBySSH.current) {
terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`);
}
});
ws.addEventListener('error', () => {
terminal.writeln(`\r\n[${t('terminal.connectionError')}]`);
});
}
async function writeTextToClipboard(text: string): Promise<void> {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return;
}
} catch (_) {
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand('copy');
} finally {
document.body.removeChild(textarea);
}
}
async function readTextFromClipboard(): Promise<string> {
try {
if (navigator.clipboard && navigator.clipboard.readText) {
return await navigator.clipboard.readText();
}
} catch (_) {
}
return '';
}
useEffect(() => {
if (!terminal || !xtermRef.current || !hostConfig) return;
terminal.options = {
cursorBlink: true,
cursorStyle: 'bar',
scrollback: 10000,
fontSize: 14,
fontFamily: '"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
theme: {background: '#18181b', foreground: '#f7f7f7'},
allowTransparency: true,
convertEol: true,
windowsMode: false,
macOptionIsMeta: false,
macOptionClickForcesSelection: false,
rightClickSelectsWord: false,
fastScrollModifier: 'alt',
fastScrollSensitivity: 5,
allowProposedApi: true,
};
const fitAddon = new FitAddon();
const clipboardAddon = new ClipboardAddon();
const unicode11Addon = new Unicode11Addon();
const webLinksAddon = new WebLinksAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(webLinksAddon);
terminal.open(xtermRef.current);
const element = xtermRef.current;
const handleContextMenu = async (e: MouseEvent) => {
if (!getUseRightClickCopyPaste()) return;
e.preventDefault();
e.stopPropagation();
try {
if (terminal.hasSelection()) {
const selection = terminal.getSelection();
if (selection) {
await writeTextToClipboard(selection);
terminal.clearSelection();
}
} else {
const pasteText = await readTextFromClipboard();
if (pasteText) terminal.paste(pasteText);
}
} catch (_) {
}
};
element?.addEventListener('contextmenu', handleContextMenu);
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => {
if (!isVisibleRef.current) return;
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
}, 100);
});
resizeObserver.observe(xtermRef.current);
const readyFonts = (document as any).fonts?.ready instanceof Promise ? (document as any).fonts.ready : Promise.resolve();
readyFonts.then(() => {
setTimeout(() => {
fitAddon.fit();
setTimeout(() => {
fitAddon.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
setVisible(true);
if (terminal && !splitScreen) {
terminal.focus();
}
}, 0);
const cols = terminal.cols;
const rows = terminal.rows;
const isDev = process.env.NODE_ENV === 'development' &&
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
const wsUrl = isDev
? 'ws://localhost:8082'
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
const ws = new WebSocket(wsUrl);
webSocketRef.current = ws;
wasDisconnectedBySSH.current = false;
setupWebSocketListeners(ws, cols, rows);
}, 300);
});
return () => {
resizeObserver.disconnect();
element?.removeEventListener('contextmenu', handleContextMenu);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
webSocketRef.current?.close();
};
}, [xtermRef, terminal, hostConfig]);
useEffect(() => {
if (isVisible && fitAddonRef.current) {
setTimeout(() => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
if (terminal && !splitScreen) {
terminal.focus();
}
}, 0);
if (terminal && !splitScreen) {
setTimeout(() => {
terminal.focus();
}, 100);
}
}
}, [isVisible, splitScreen, terminal]);
useEffect(() => {
if (!fitAddonRef.current) return;
setTimeout(() => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
if (terminal && !splitScreen && isVisible) {
terminal.focus();
}
}, 0);
}, [splitScreen, isVisible, terminal]);
return (
<div
ref={xtermRef}
className="h-full w-full m-1"
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
onClick={() => {
if (terminal && !splitScreen) {
terminal.focus();
}
}}
/>
);
});
const style = document.createElement('style');
style.innerHTML = `
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
/* Load NerdFonts locally */
@font-face {
font-family: 'JetBrains Mono Nerd Font';
src: url('/fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono Nerd Font';
src: url('/fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono Nerd Font';
src: url('/fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
font-weight: normal;
font-style: italic;
font-display: swap;
}
.xterm .xterm-viewport::-webkit-scrollbar {
width: 8px;
background: transparent;
}
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
background: rgba(180,180,180,0.7);
border-radius: 4px;
}
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(120,120,120,0.9);
}
.xterm .xterm-viewport {
scrollbar-width: thin;
scrollbar-color: rgba(180,180,180,0.7) transparent;
}
.xterm {
font-feature-settings: "liga" 1, "calt" 1;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.xterm .xterm-screen {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font', 'Cascadia Code', 'JetBrains Mono', Consolas, "Courier New", monospace !important;
font-variant-ligatures: contextual;
}
.xterm .xterm-screen .xterm-char {
font-feature-settings: "liga" 1, "calt" 1;
}
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE"] {
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
}
`;
document.head.appendChild(style);

View File

@@ -0,0 +1,204 @@
import React, {useState, useEffect, useCallback} from "react";
import {TunnelViewer} from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
import {getSSHHosts, getTunnelStatuses, connectTunnel, disconnectTunnel, cancelTunnel} from "@/ui/main-axios.ts";
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface SSHTunnelProps {
filterHostKey?: string;
}
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 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 fetchTunnelStatuses = useCallback(async () => {
const statusData = await getTunnelStatuses();
setTunnelStatuses(statusData);
}, []);
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(() => {
fetchTunnelStatuses();
const interval = setInterval(fetchTunnelStatuses, 500);
return () => clearInterval(interval);
}, [fetchTunnelStatuses]);
const handleTunnelAction = async (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${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
);
if (!endpointHost) {
throw new Error('Endpoint host not found');
}
const tunnelConfig = {
name: tunnelName,
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,
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,
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval * 1000,
autoStart: tunnel.autoStart,
isPinned: host.pin
};
await connectTunnel(tunnelConfig);
} else if (action === 'disconnect') {
await disconnectTunnel(tunnelName);
} else if (action === 'cancel') {
await cancelTunnel(tunnelName);
}
await fetchTunnelStatuses();
} catch (err) {
} finally {
setTunnelActions(prev => ({...prev, [tunnelName]: false}));
}
};
return (
<TunnelViewer
hosts={visibleHosts}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={handleTunnelAction}
/>
);
}

View File

@@ -0,0 +1,490 @@
import React from "react";
import {Button} from "@/components/ui/button.tsx";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {useTranslation} from 'react-i18next';
import {
Loader2,
Pin,
Terminal,
Network,
FileEdit,
Tag,
Play,
Square,
AlertCircle,
Clock,
Wifi,
WifiOff,
Zap,
X
} from "lucide-react";
import {Badge} from "@/components/ui/badge.tsx";
const CONNECTION_STATES = {
DISCONNECTED: "disconnected",
CONNECTING: "connecting",
CONNECTED: "connected",
VERIFYING: "verifying",
FAILED: "failed",
UNSTABLE: "unstable",
RETRYING: "retrying",
WAITING: "waiting",
DISCONNECTING: "disconnecting"
};
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface SSHTunnelObjectProps {
host: SSHHost;
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
compact?: boolean;
bare?: boolean;
}
export function TunnelObject({
host,
tunnelStatuses,
tunnelActions,
onTunnelAction,
compact = false,
bare = false
}: SSHTunnelObjectProps): React.ReactElement {
const {t} = useTranslation();
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${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: statusValue,
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">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
const status = getTunnelStatus(tunnelIndex);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${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={tunnelIndex}
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, tunnelIndex)}
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.disconnect')}
</Button>
</>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
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, tunnelIndex)}
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.connect')}
</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">Discord</a> or
create a <a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">GitHub
issue</a> for help.
</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')} ({host.tunnelConnections.length})
</h4>
)}
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
const status = getTunnelStatus(tunnelIndex);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${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={tunnelIndex}
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, tunnelIndex)}
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.disconnect')}
</Button>
</>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() => onTunnelAction('cancel', host, tunnelIndex)}
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, tunnelIndex)}
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.connect')}
</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">Discord</a> or
create a <a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank" rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400">GitHub
issue</a> for help.
</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>
);
}

View File

@@ -0,0 +1,93 @@
import React from "react";
import {TunnelObject} from "./TunnelObject.tsx";
import {useTranslation} from 'react-i18next';
interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
interface TunnelStatus {
status: string;
reason?: string;
errorType?: string;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
retryExhausted?: boolean;
}
interface SSHTunnelViewerProps {
hosts: SSHHost[];
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (action: 'connect' | 'disconnect' | 'cancel', host: SSHHost, tunnelIndex: number) => Promise<any>;
}
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="w-full flex-shrink-0 mb-2">
<h1 className="text-xl font-semibold text-foreground">{t('tunnels.title')}</h1>
</div>
<div className="min-h-0 flex-1 overflow-auto 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}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
host={{...activeHost, tunnelConnections: [activeHost.tunnelConnections[idx]]}}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={(action, _host, _index) => onTunnelAction(action, activeHost, idx)}
compact
bare
/>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,228 @@
import React, {useState, useEffect} from "react"
import {LeftSidebar} from "@/ui/Desktop/Navigation/LeftSidebar.tsx"
import {Homepage} from "@/ui/Homepage/Homepage.tsx"
import {AppView} from "@/ui/Desktop/Navigation/AppView.tsx"
import {HostManager} from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx"
import {TabProvider, useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx"
import {TopNavbar} from "@/ui/Desktop/Navigation/TopNavbar.tsx";
import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner.tsx";
import { getUserInfo } from "@/ui/main-axios.ts";
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
function AppContent() {
const [view, setView] = useState<string>("homepage")
const [mountedViews, setMountedViews] = useState<Set<string>>(new Set(["homepage"]))
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [username, setUsername] = useState<string | null>(null)
const [isAdmin, setIsAdmin] = useState(false)
const [authLoading, setAuthLoading] = useState(true)
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true)
const {currentTab, tabs} = useTabs();
useEffect(() => {
const checkAuth = () => {
const jwt = getCookie("jwt");
if (jwt) {
setAuthLoading(true);
getUserInfo()
.then((meRes) => {
setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
})
.catch((err) => {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
})
.finally(() => setAuthLoading(false));
} else {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
setAuthLoading(false);
}
}
checkAuth()
const handleStorageChange = () => checkAuth()
window.addEventListener('storage', handleStorageChange)
return () => window.removeEventListener('storage', handleStorageChange)
}, [])
const handleSelectView = (nextView: string) => {
setMountedViews((prev) => {
if (prev.has(nextView)) return prev
const next = new Set(prev)
next.add(nextView)
return next
})
setView(nextView)
}
const handleAuthSuccess = (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => {
setIsAuthenticated(true)
setIsAdmin(authData.isAdmin)
setUsername(authData.username)
}
const currentTabData = tabs.find(tab => tab.id === currentTab);
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
const showHome = currentTabData?.type === 'home';
const showSshManager = currentTabData?.type === 'ssh_manager';
const showAdmin = currentTabData?.type === 'admin';
const showProfile = currentTabData?.type === 'profile';
return (
<div>
{!isAuthenticated && !authLoading && (
<div>
<div className="absolute inset-0" style={{
backgroundImage: `linear-gradient(
135deg,
transparent 0%,
transparent 49%,
rgba(255, 255, 255, 0.03) 49%,
rgba(255, 255, 255, 0.03) 51%,
transparent 51%,
transparent 100%
)`,
backgroundSize: '80px 80px'
}} />
</div>
)}
{!isAuthenticated && !authLoading && (
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
<Homepage
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
</div>
)}
{isAuthenticated && (
<LeftSidebar
onSelectView={handleSelectView}
disabled={!isAuthenticated || authLoading}
isAdmin={isAdmin}
username={username}
>
<div
className="h-screen w-full"
style={{
visibility: showTerminalView ? "visible" : "hidden",
pointerEvents: showTerminalView ? "auto" : "none",
height: showTerminalView ? "100vh" : 0,
width: showTerminalView ? "100%" : 0,
position: showTerminalView ? "static" : "absolute",
overflow: "hidden",
}}
>
<AppView isTopbarOpen={isTopbarOpen} />
</div>
<div
className="h-screen w-full"
style={{
visibility: showHome ? "visible" : "hidden",
pointerEvents: showHome ? "auto" : "none",
height: showHome ? "100vh" : 0,
width: showHome ? "100%" : 0,
position: showHome ? "static" : "absolute",
overflow: "hidden",
}}
>
<Homepage
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
</div>
<div
className="h-screen w-full"
style={{
visibility: showSshManager ? "visible" : "hidden",
pointerEvents: showSshManager ? "auto" : "none",
height: showSshManager ? "100vh" : 0,
width: showSshManager ? "100%" : 0,
position: showSshManager ? "static" : "absolute",
overflow: "hidden",
}}
>
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
</div>
<div
className="h-screen w-full"
style={{
visibility: showAdmin ? "visible" : "hidden",
pointerEvents: showAdmin ? "auto" : "none",
height: showAdmin ? "100vh" : 0,
width: showAdmin ? "100%" : 0,
position: showAdmin ? "static" : "absolute",
overflow: "hidden",
}}
>
<AdminSettings isTopbarOpen={isTopbarOpen} />
</div>
<div
className="h-screen w-full"
style={{
visibility: showProfile ? "visible" : "hidden",
pointerEvents: showProfile ? "auto" : "none",
height: showProfile ? "100vh" : 0,
width: showProfile ? "100%" : 0,
position: showProfile ? "static" : "absolute",
overflow: "auto",
}}
>
<UserProfile isTopbarOpen={isTopbarOpen} />
</div>
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
</LeftSidebar>
)}
<Toaster
position="bottom-right"
richColors={false}
closeButton
duration={5000}
offset={20}
/>
</div>
)
}
function DesktopApp() {
return (
<TabProvider>
<AppContent />
</TabProvider>
);
}
export default DesktopApp

View File

@@ -0,0 +1,170 @@
import React, {useEffect, useState} from "react";
import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx";
import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx";
import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx";
import {Button} from "@/components/ui/button.tsx";
import { getUserInfo, getDatabaseHealth } from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next";
interface HomepageProps {
onSelectView: (view: string) => void;
isAuthenticated: boolean;
authLoading: boolean;
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
isTopbarOpen?: boolean;
}
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
export function Homepage({
onSelectView,
isAuthenticated,
authLoading,
onAuthSuccess,
isTopbarOpen = true
}: HomepageProps): React.ReactElement {
const {t} = useTranslation();
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
const [isAdmin, setIsAdmin] = useState(false);
const [username, setUsername] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [dbError, setDbError] = useState<string | null>(null);
useEffect(() => {
setLoggedIn(isAuthenticated);
}, [isAuthenticated]);
useEffect(() => {
if (isAuthenticated) {
const jwt = getCookie("jwt");
if (jwt) {
Promise.all([
getUserInfo(),
getDatabaseHealth()
])
.then(([meRes]) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
})
.catch((err) => {
setIsAdmin(false);
setUsername(null);
setUserId(null);
if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later.");
} else {
setDbError(null);
}
});
}
}
}, [isAuthenticated]);
const topOffset = isTopbarOpen ? 66 : 0;
const topPadding = isTopbarOpen ? 66 : 0;
return (
<div
className="w-full min-h-svh relative transition-[padding-top] duration-300 ease-in-out"
style={{ paddingTop: `${topPadding}px` }}>
{!loggedIn ? (
<div
className="absolute left-0 w-full flex items-center justify-center transition-all duration-300 ease-in-out"
style={{
top: `${topOffset}px`,
height: `calc(100% - ${topOffset}px)`
}}>
<HomepageAuth
setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin}
setUsername={setUsername}
setUserId={setUserId}
loggedIn={loggedIn}
authLoading={authLoading}
dbError={dbError}
setDbError={setDbError}
onAuthSuccess={onAuthSuccess}
/>
</div>
) : (
<div
className="absolute left-0 w-full flex items-center justify-center transition-all duration-300 ease-in-out"
style={{
top: `${topOffset}px`,
height: `calc(100% - ${topOffset}px)`
}}>
<div className="flex flex-row items-center justify-center gap-8 relative z-10">
<div className="flex flex-col items-center gap-6 w-[400px]">
<div
className="text-center bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 w-full shadow-lg">
<h3 className="text-xl font-bold mb-3 text-white">{t('homepage.loggedInTitle')}</h3>
<p className="text-gray-300 leading-relaxed">
{t('homepage.loggedInMessage')}
</p>
</div>
<div className="flex flex-row items-center gap-3">
<Button
variant="outline"
size="sm"
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
>
GitHub
</Button>
<div className="w-px h-4 bg-[#303032]"></div>
<Button
variant="outline"
size="sm"
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
>
Feedback
</Button>
<div className="w-px h-4 bg-[#303032]"></div>
<Button
variant="outline"
size="sm"
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
>
Discord
</Button>
<div className="w-px h-4 bg-[#303032]"></div>
<Button
variant="outline"
size="sm"
className="text-sm border-[#303032] text-gray-300 hover:text-white hover:bg-[#18181b] transition-colors"
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
>
Donate
</Button>
</div>
</div>
<HomepageUpdateLog
loggedIn={loggedIn}
/>
</div>
</div>
)}
<HomepageAlertManager
userId={userId}
loggedIn={loggedIn}
/>
</div>
);
}

View File

@@ -0,0 +1,153 @@
import React from "react";
import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Badge} from "@/components/ui/badge.tsx";
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react";
import {useTranslation} from "react-i18next";
interface TermixAlert {
id: string;
title: string;
message: string;
expiresAt: string;
priority?: 'low' | 'medium' | 'high' | 'critical';
type?: 'info' | 'warning' | 'error' | 'success';
actionUrl?: string;
actionText?: string;
}
interface AlertCardProps {
alert: TermixAlert;
onDismiss: (alertId: string) => void;
onClose: () => void;
}
const getAlertIcon = (type?: string) => {
switch (type) {
case 'warning':
return <AlertTriangle className="h-5 w-5 text-yellow-500"/>;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500"/>;
case 'success':
return <CheckCircle className="h-5 w-5 text-green-500"/>;
case 'info':
default:
return <Info className="h-5 w-5 text-blue-500"/>;
}
};
const getPriorityBadgeVariant = (priority?: string) => {
switch (priority) {
case 'critical':
return 'destructive';
case 'high':
return 'destructive';
case 'medium':
return 'secondary';
case 'low':
default:
return 'outline';
}
};
const getTypeBadgeVariant = (type?: string) => {
switch (type) {
case 'warning':
return 'secondary';
case 'error':
return 'destructive';
case 'success':
return 'default';
case 'info':
default:
return 'outline';
}
};
export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement {
const {t} = useTranslation();
if (!alert) {
return null;
}
const handleDismiss = () => {
onDismiss(alert.id);
onClose();
};
const formatExpiryDate = (expiryString: string) => {
const expiryDate = new Date(expiryString);
const now = new Date();
const diffTime = expiryDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) return t('common.expired');
if (diffDays === 0) return t('common.expiresToday');
if (diffDays === 1) return t('common.expiresTomorrow');
return t('common.expiresInDays', {days: diffDays});
};
return (
<Card className="w-full max-w-2xl mx-auto">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{getAlertIcon(alert.type)}
<CardTitle className="text-xl font-bold">
{alert.title}
</CardTitle>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4"/>
</Button>
</div>
<div className="flex items-center gap-2 mt-2">
{alert.priority && (
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
{alert.priority.toUpperCase()}
</Badge>
)}
{alert.type && (
<Badge variant={getTypeBadgeVariant(alert.type)}>
{alert.type}
</Badge>
)}
<span className="text-sm text-muted-foreground">
{formatExpiryDate(alert.expiresAt)}
</span>
</div>
</CardHeader>
<CardContent className="pb-4">
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
{alert.message}
</p>
</CardContent>
<CardFooter className="flex items-center justify-between pt-0">
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleDismiss}
>
Dismiss
</Button>
{alert.actionUrl && alert.actionText && (
<Button
variant="default"
onClick={() => window.open(alert.actionUrl, '_blank', 'noopener,noreferrer')}
className="gap-2"
>
{alert.actionText}
<ExternalLink className="h-4 w-4"/>
</Button>
)}
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,179 @@
import React, {useEffect, useState} from "react";
import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
import {Button} from "@/components/ui/button.tsx";
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next";
interface TermixAlert {
id: string;
title: string;
message: string;
expiresAt: string;
priority?: 'low' | 'medium' | 'high' | 'critical';
type?: 'info' | 'warning' | 'error' | 'success';
actionUrl?: string;
actionText?: string;
}
interface AlertManagerProps {
userId: string | null;
loggedIn: boolean;
}
export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement {
const {t} = useTranslation();
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (loggedIn && userId) {
fetchUserAlerts();
}
}, [loggedIn, userId]);
const fetchUserAlerts = async () => {
if (!userId) return;
setLoading(true);
setError(null);
try {
const response = await getUserAlerts(userId);
const userAlerts = response.alerts || [];
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
const priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
if (aPriority !== bPriority) {
return bPriority - aPriority;
}
return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
});
setAlerts(sortedAlerts);
setCurrentAlertIndex(0);
} catch (err) {
setError(t('homepage.failedToLoadAlerts'));
} finally {
setLoading(false);
}
};
const handleDismissAlert = async (alertId: string) => {
if (!userId) return;
try {
await dismissAlert(userId, alertId);
setAlerts(prev => {
const newAlerts = prev.filter(alert => alert.id !== alertId);
return newAlerts;
});
setCurrentAlertIndex(prevIndex => {
const newAlertsLength = alerts.length - 1;
if (newAlertsLength === 0) return 0;
if (prevIndex >= newAlertsLength) return Math.max(0, newAlertsLength - 1);
return prevIndex;
});
} catch (err) {
setError(t('homepage.failedToDismissAlert'));
}
};
const handleCloseCurrentAlert = () => {
if (alerts.length === 0) return;
if (currentAlertIndex < alerts.length - 1) {
setCurrentAlertIndex(currentAlertIndex + 1);
} else {
setAlerts([]);
setCurrentAlertIndex(0);
}
};
const handlePreviousAlert = () => {
if (currentAlertIndex > 0) {
setCurrentAlertIndex(currentAlertIndex - 1);
}
};
const handleNextAlert = () => {
if (currentAlertIndex < alerts.length - 1) {
setCurrentAlertIndex(currentAlertIndex + 1);
}
};
if (!loggedIn || !userId) {
return null;
}
if (alerts.length === 0) {
return null;
}
const currentAlert = alerts[currentAlertIndex];
if (!currentAlert) {
return null;
}
const priorityCounts = {critical: 0, high: 0, medium: 0, low: 0};
alerts.forEach(alert => {
const priority = alert.priority || 'low';
priorityCounts[priority as keyof typeof priorityCounts]++;
});
const hasMultipleAlerts = alerts.length > 1;
return (
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
<div className="relative w-full max-w-2xl mx-4">
<HomepageAlertCard
alert={currentAlert}
onDismiss={handleDismissAlert}
onClose={handleCloseCurrentAlert}
/>
{hasMultipleAlerts && (
<div className="absolute -bottom-16 left-1/2 transform -translate-x-1/2 flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePreviousAlert}
disabled={currentAlertIndex === 0}
className="h-8 px-3"
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
{currentAlertIndex + 1} of {alerts.length}
</span>
<Button
variant="outline"
size="sm"
onClick={handleNextAlert}
disabled={currentAlertIndex === alerts.length - 1}
className="h-8 px-3"
>
Next
</Button>
</div>
)}
{error && (
<div className="absolute -bottom-20 left-1/2 transform -translate-x-1/2">
<div className="bg-destructive text-destructive-foreground px-3 py-1 rounded text-sm">
{error}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,801 @@
import React, {useState, useEffect} from "react";
import {cn} from "../../lib/utils.ts";
import {Button} from "../../components/ui/button.tsx";
import {Input} from "../../components/ui/input.tsx";
import {Label} from "../../components/ui/label.tsx";
import {Alert, AlertTitle, AlertDescription} from "../../components/ui/alert.tsx";
import {useTranslation} from "react-i18next";
import {LanguageSwitcher} from "../../components/LanguageSwitcher";
import {
registerUser,
loginUser,
getUserInfo,
getRegistrationAllowed,
getOIDCConfig,
getUserCount,
initiatePasswordReset,
verifyPasswordResetCode,
completePasswordReset,
getOIDCAuthorizeUrl,
verifyTOTPLogin
} from "../main-axios.ts";
function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
interface HomepageAuthProps extends React.ComponentProps<"div"> {
setLoggedIn: (loggedIn: boolean) => void;
setIsAdmin: (isAdmin: boolean) => void;
setUsername: (username: string | null) => void;
setUserId: (userId: string | null) => void;
loggedIn: boolean;
authLoading: boolean;
dbError: string | null;
setDbError: (error: string | null) => void;
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
}
export function HomepageAuth({
className,
setLoggedIn,
setIsAdmin,
setUsername,
setUserId,
loggedIn,
authLoading,
dbError,
setDbError,
onAuthSuccess,
...props
}: HomepageAuthProps) {
const {t} = useTranslation();
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login");
const [localUsername, setLocalUsername] = useState("");
const [password, setPassword] = useState("");
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [oidcLoading, setOidcLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
const [firstUser, setFirstUser] = useState(false);
const [registrationAllowed, setRegistrationAllowed] = useState(true);
const [oidcConfigured, setOidcConfigured] = useState(false);
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
const [resetCode, setResetCode] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [tempToken, setTempToken] = useState("");
const [resetLoading, setResetLoading] = useState(false);
const [resetSuccess, setResetSuccess] = useState(false);
const [totpRequired, setTotpRequired] = useState(false);
const [totpCode, setTotpCode] = useState("");
const [totpTempToken, setTotpTempToken] = useState("");
const [totpLoading, setTotpLoading] = useState(false);
useEffect(() => {
setInternalLoggedIn(loggedIn);
}, [loggedIn]);
useEffect(() => {
getRegistrationAllowed().then(res => {
setRegistrationAllowed(res.allowed);
});
}, []);
useEffect(() => {
getOIDCConfig().then((response) => {
if (response) {
setOidcConfigured(true);
} else {
setOidcConfigured(false);
}
}).catch((error) => {
if (error.response?.status === 404) {
setOidcConfigured(false);
} else {
setOidcConfigured(false);
}
});
}, []);
useEffect(() => {
getUserCount().then(res => {
if (res.count === 0) {
setFirstUser(true);
setTab("signup");
} else {
setFirstUser(false);
}
setDbError(null);
}).catch(() => {
setDbError(t('errors.databaseConnection'));
});
}, [setDbError]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
if (!localUsername.trim()) {
setError(t('errors.requiredField'));
setLoading(false);
return;
}
try {
let res, meRes;
if (tab === "login") {
res = await loginUser(localUsername, password);
} else {
if (password !== signupConfirmPassword) {
setError(t('errors.passwordMismatch'));
setLoading(false);
return;
}
if (password.length < 6) {
setError(t('errors.minLength', {min: 6}));
setLoading(false);
return;
}
await registerUser(localUsername, password);
res = await loginUser(localUsername, password);
}
if (res.requires_totp) {
setTotpRequired(true);
setTotpTempToken(res.temp_token);
setLoading(false);
return;
}
if (!res || !res.token) {
throw new Error(t('errors.noTokenReceived'));
}
setCookie("jwt", res.token);
[meRes] = await Promise.all([
getUserInfo(),
]);
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null
});
setInternalLoggedIn(true);
if (tab === "signup") {
setSignupConfirmPassword("");
}
setTotpRequired(false);
setTotpCode("");
setTotpTempToken("");
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || t('errors.unknownError'));
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) {
setDbError(t('errors.databaseConnection'));
} else {
setDbError(null);
}
} finally {
setLoading(false);
}
}
async function handleInitiatePasswordReset() {
setError(null);
setResetLoading(true);
try {
const result = await initiatePasswordReset(localUsername);
setResetStep("verify");
setError(null);
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || t('errors.failedPasswordReset'));
} finally {
setResetLoading(false);
}
}
async function handleVerifyResetCode() {
setError(null);
setResetLoading(true);
try {
const response = await verifyPasswordResetCode(localUsername, resetCode);
setTempToken(response.tempToken);
setResetStep("newPassword");
setError(null);
} catch (err: any) {
setError(err?.response?.data?.error || t('errors.failedVerifyCode'));
} finally {
setResetLoading(false);
}
}
async function handleCompletePasswordReset() {
setError(null);
setResetLoading(true);
if (newPassword !== confirmPassword) {
setError(t('errors.passwordMismatch'));
setResetLoading(false);
return;
}
if (newPassword.length < 6) {
setError(t('errors.minLength', {min: 6}));
setResetLoading(false);
return;
}
try {
await completePasswordReset(localUsername, tempToken, newPassword);
setResetStep("initiate");
setResetCode("");
setNewPassword("");
setConfirmPassword("");
setTempToken("");
setError(null);
setResetSuccess(true);
} catch (err: any) {
setError(err?.response?.data?.error || t('errors.failedCompleteReset'));
} finally {
setResetLoading(false);
}
}
function resetPasswordState() {
setResetStep("initiate");
setResetCode("");
setNewPassword("");
setConfirmPassword("");
setTempToken("");
setError(null);
setResetSuccess(false);
setSignupConfirmPassword("");
}
function clearFormFields() {
setPassword("");
setSignupConfirmPassword("");
setError(null);
}
async function handleTOTPVerification() {
if (totpCode.length !== 6) {
setError(t('auth.enterCode'));
return;
}
setError(null);
setTotpLoading(true);
try {
const res = await verifyTOTPLogin(totpTempToken, totpCode);
if (!res || !res.token) {
throw new Error(t('errors.noTokenReceived'));
}
setCookie("jwt", res.token);
const meRes = await getUserInfo();
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.userId || null
});
setInternalLoggedIn(true);
setTotpRequired(false);
setTotpCode("");
setTotpTempToken("");
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || t('errors.invalidTotpCode'));
} finally {
setTotpLoading(false);
}
}
async function handleOIDCLogin() {
setError(null);
setOidcLoading(true);
try {
const authResponse = await getOIDCAuthorizeUrl();
const {auth_url: authUrl} = authResponse;
if (!authUrl || authUrl === 'undefined') {
throw new Error(t('errors.invalidAuthUrl'));
}
window.location.replace(authUrl);
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || t('errors.failedOidcLogin'));
setOidcLoading(false);
}
}
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const success = urlParams.get('success');
const token = urlParams.get('token');
const error = urlParams.get('error');
if (error) {
setError(`${t('errors.oidcAuthFailed')}: ${error}`);
setOidcLoading(false);
window.history.replaceState({}, document.title, window.location.pathname);
return;
}
if (success && token) {
setOidcLoading(true);
setError(null);
setCookie("jwt", token);
getUserInfo()
.then(meRes => {
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.id || null);
setDbError(null);
onAuthSuccess({
isAdmin: !!meRes.is_admin,
username: meRes.username || null,
userId: meRes.id || null
});
setInternalLoggedIn(true);
window.history.replaceState({}, document.title, window.location.pathname);
})
.catch(err => {
setError(t('errors.failedUserInfo'));
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
setUsername(null);
setUserId(null);
setCookie("jwt", "", -1);
window.history.replaceState({}, document.title, window.location.pathname);
})
.finally(() => {
setOidcLoading(false);
});
}
}, []);
const Spinner = (
<svg className="animate-spin mr-2 h-4 w-4 text-white 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 (
<div
className={`w-[420px] max-w-full p-6 flex flex-col bg-[#18181b] border-2 border-[#303032] rounded-md ${className || ''}`}
{...props}
>
{dbError && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{dbError}</AlertDescription>
</Alert>
)}
{firstUser && !dbError && !internalLoggedIn && (
<Alert variant="default" className="mb-4">
<AlertTitle>{t('auth.firstUser')}</AlertTitle>
<AlertDescription className="inline">
{t('auth.firstUserMessage')}{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800 inline"
>
GitHub Issue
</a>.
</AlertDescription>
</Alert>
)}
{!registrationAllowed && !internalLoggedIn && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>{t('auth.registerTitle')}</AlertTitle>
<AlertDescription>
{t('messages.registrationDisabled')}
</AlertDescription>
</Alert>
)}
{totpRequired && (
<div className="flex flex-col gap-5">
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">{t('auth.twoFactorAuth')}</h2>
<p className="text-muted-foreground">{t('auth.enterCode')}</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="totp-code">{t('auth.verifyCode')}</Label>
<Input
id="totp-code"
type="text"
placeholder="000000"
maxLength={6}
value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
disabled={totpLoading}
className="text-center text-2xl tracking-widest font-mono"
autoComplete="one-time-code"
/>
<p className="text-xs text-muted-foreground text-center">
{t('auth.backupCode')}
</p>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={totpLoading || totpCode.length < 6}
onClick={handleTOTPVerification}
>
{totpLoading ? Spinner : t('auth.verifyCode')}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={totpLoading}
onClick={() => {
setTotpRequired(false);
setTotpCode("");
setTotpTempToken("");
setError(null);
}}
>
{t('common.cancel')}
</Button>
</div>
)}
{(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && (
<>
<div className="flex gap-2 mb-6">
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "login"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => {
setTab("login");
if (tab === "reset") resetPasswordState();
if (tab === "signup") clearFormFields();
}}
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
{t('common.login')}
</button>
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "signup"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => {
setTab("signup");
if (tab === "reset") resetPasswordState();
if (tab === "login") clearFormFields();
}}
aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed}
>
{t('common.register')}
</button>
{oidcConfigured && (
<button
type="button"
className={cn(
"flex-1 py-2 text-base font-medium rounded-md transition-all",
tab === "external"
? "bg-primary text-primary-foreground shadow"
: "bg-muted text-muted-foreground hover:bg-accent"
)}
onClick={() => {
setTab("external");
if (tab === "reset") resetPasswordState();
if (tab === "login" || tab === "signup") clearFormFields();
}}
aria-selected={tab === "external"}
disabled={oidcLoading}
>
{t('auth.external')}
</button>
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login" ? t('auth.loginTitle') :
tab === "signup" ? t('auth.registerTitle') :
tab === "external" ? t('auth.loginWithExternal') :
t('auth.forgotPassword')}
</h2>
</div>
{tab === "external" || tab === "reset" ? (
<div className="flex flex-col gap-5">
{tab === "external" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t('auth.loginWithExternalDesc')}</p>
</div>
<Button
type="button"
className="w-full h-11 mt-2 text-base font-semibold"
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : t('auth.loginWithExternal')}
</Button>
</>
)}
{tab === "reset" && (
<>
{resetStep === "initiate" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t('auth.resetCodeDesc')}</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">{t('common.username')}</Label>
<Input
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={resetLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !localUsername.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : t('auth.sendResetCode')}
</Button>
</div>
</>
)}
{resetStep === "verify" && (
<>o
<div className="text-center text-muted-foreground mb-4">
<p>{t('auth.enterResetCode')} <strong>{localUsername}</strong></p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">{t('auth.resetCode')}</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
disabled={resetLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode}
>
{resetLoading ? Spinner : t('auth.verifyCodeButton')}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
{t('common.back')}
</Button>
</div>
</>
)}
{resetSuccess && (
<>
<Alert className="mb-4">
<AlertTitle>{t('auth.passwordResetSuccess')}</AlertTitle>
<AlertDescription>
{t('auth.passwordResetSuccessDesc')}
</AlertDescription>
</Alert>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
onClick={() => {
setTab("login");
resetPasswordState();
}}
>
{t('auth.goToLogin')}
</Button>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t('auth.enterNewPassword')} <strong>{localUsername}</strong></p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">{t('auth.newPassword')}</Label>
<Input
id="new-password"
type="password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">{t('auth.confirmNewPassword')}</Label>
<Input
id="confirm-password"
type="password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !newPassword || !confirmPassword}
onClick={handleCompletePasswordReset}
>
{resetLoading ? Spinner : t('auth.resetPasswordButton')}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
{t('common.back')}
</Button>
</div>
</>
)}
</>
)}
</div>
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="username">{t('common.username')}</Label>
<Input
id="username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={e => setLocalUsername(e.target.value)}
disabled={loading || internalLoggedIn}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">{t('common.password')}</Label>
<Input id="password" type="password" required className="h-11 text-base"
value={password} onChange={e => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">{t('common.confirmPassword')}</Label>
<Input id="signup-confirm-password" type="password" required
className="h-11 text-base"
value={signupConfirmPassword}
onChange={e => setSignupConfirmPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div>
)}
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}>
{loading ? Spinner : (tab === "login" ? t('common.login') : t('auth.signUp'))}
</Button>
{tab === "login" && (
<Button type="button" variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={loading || internalLoggedIn}
onClick={() => {
setTab("reset");
resetPasswordState();
clearFormFields();
}}
>
{t('auth.resetPasswordButton')}
</Button>
)}
</form>
)}
<div className="mt-6 pt-4 border-t border-[#303032]">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm text-muted-foreground">{t('common.language')}</Label>
</div>
<LanguageSwitcher />
</div>
</div>
</>
)}
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
);
}

View File

@@ -0,0 +1,172 @@
import React, {useEffect, useState} from "react";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next";
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
loggedIn: boolean;
}
interface ReleaseItem {
id: number;
title: string;
description: string;
link: string;
pubDate: string;
version: string;
isPrerelease: boolean;
isDraft: boolean;
assets: Array<{
name: string;
size: number;
download_count: number;
download_url: string;
}>;
}
interface RSSResponse {
feed: {
title: string;
description: string;
link: string;
updated: string;
};
items: ReleaseItem[];
total_count: number;
cached: boolean;
cache_age?: number;
}
interface VersionResponse {
status: 'up_to_date' | 'requires_update';
version: string;
latest_release: {
name: string;
published_at: string;
html_url: string;
};
cached: boolean;
cache_age?: number;
}
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
const {t} = useTranslation();
const [releases, setReleases] = useState<RSSResponse | null>(null);
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (loggedIn) {
setLoading(true);
Promise.all([
getReleasesRSS(100),
getVersionInfo()
])
.then(([releasesRes, versionRes]) => {
setReleases(releasesRes);
setVersionInfo(versionRes);
setError(null);
})
.catch(err => {
setError(t('common.failedToFetchUpdateInfo'));
})
.finally(() => setLoading(false));
}
}, [loggedIn]);
if (!loggedIn) {
return null;
}
const formatDescription = (description: string) => {
const firstLine = description.split('\n')[0];
return firstLine
.replace(/[#*`]/g, '')
.replace(/\s+/g, ' ')
.trim();
};
return (
<div className="w-[400px] h-[600px] flex flex-col border-2 border-[#303032] rounded-lg bg-[#18181b] p-4 shadow-lg">
<div>
<h3 className="text-lg font-bold mb-3 text-white">{t('common.updatesAndReleases')}</h3>
<Separator className="p-0.25 mt-3 mb-3 bg-[#303032]"/>
{versionInfo && versionInfo.status === 'requires_update' && (
<Alert className="bg-[#0e0e10] border-[#303032] text-white">
<AlertTitle className="text-white">{t('common.updateAvailable')}</AlertTitle>
<AlertDescription className="text-gray-300">
{t('common.newVersionAvailable', { version: versionInfo.version })}
</AlertDescription>
</Alert>
)}
</div>
{versionInfo && versionInfo.status === 'requires_update' && (
<Separator className="p-0.25 mt-3 mb-3 bg-[#303032]"/>
)}
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
{loading && (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
)}
{error && (
<Alert variant="destructive" className="bg-red-900/20 border-red-500 text-red-300">
<AlertTitle className="text-red-300">{t('common.error')}</AlertTitle>
<AlertDescription className="text-red-300">{error}</AlertDescription>
</Alert>
)}
{releases?.items.map((release) => (
<div
key={release.id}
className="border border-[#303032] rounded-lg p-3 hover:bg-[#0e0e10] transition-colors cursor-pointer bg-[#0e0e10]/50"
onClick={() => window.open(release.link, '_blank')}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-sm leading-tight flex-1 text-white">
{release.title}
</h4>
{release.isPrerelease && (
<span
className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
{t('common.preRelease')}
</span>
)}
</div>
<p className="text-xs text-gray-300 mb-2 leading-relaxed">
{formatDescription(release.description)}
</p>
<div className="flex items-center text-xs text-gray-400">
<span>{new Date(release.pubDate).toLocaleDateString()}</span>
{release.assets.length > 0 && (
<>
<span className="mx-2"></span>
<span>{release.assets.length} asset{release.assets.length !== 1 ? 's' : ''}</span>
</>
)}
</div>
</div>
))}
{releases && releases.items.length === 0 && !loading && (
<Alert className="bg-[#0e0e10] border-[#303032] text-gray-300">
<AlertTitle className="text-gray-300">{t('common.noReleases')}</AlertTitle>
<AlertDescription className="text-gray-400">
{t('common.noReleasesFound')}
</AlertDescription>
</Alert>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,553 @@
import React, {useEffect, useRef, useState} from "react";
import {Terminal} from "@/ui/Desktop/Apps/Terminal/Terminal.tsx";
import {Server as ServerView} from "@/ui/Desktop/Apps/Server/Server.tsx";
import {FileManager} from "@/ui/Desktop/Apps/File Manager/FileManager.tsx";
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
import * as ResizablePrimitive from "react-resizable-panels";
import {useSidebar} from "@/components/ui/sidebar.tsx";
import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react";
import {Button} from "@/components/ui/button.tsx";
interface TerminalViewProps {
isTopbarOpen?: boolean;
}
export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactElement {
const {tabs, currentTab, allSplitScreenTab} = useTabs() as any;
const {state: sidebarState} = useSidebar();
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal' || tab.type === 'server' || tab.type === 'file_manager');
const containerRef = useRef<HTMLDivElement | null>(null);
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>({});
const [ready, setReady] = useState<boolean>(true);
const [resetKey, setResetKey] = useState<number>(0);
const updatePanelRects = () => {
const next: Record<string, DOMRect | null> = {};
Object.entries(panelRefs.current).forEach(([id, el]) => {
if (el) next[id] = el.getBoundingClientRect();
});
setPanelRects(next);
};
const fitActiveAndNotify = () => {
const visibleIds: number[] = [];
if (allSplitScreenTab.length === 0) {
if (currentTab) visibleIds.push(currentTab);
} else {
const splitIds = allSplitScreenTab as number[];
visibleIds.push(currentTab, ...splitIds.filter((i) => i !== currentTab));
}
terminalTabs.forEach((t: any) => {
if (visibleIds.includes(t.id)) {
const ref = t.terminalRef?.current;
if (ref?.fit) ref.fit();
if (ref?.notifyResize) ref.notifyResize();
if (ref?.refresh) ref.refresh();
}
});
};
const layoutScheduleRef = useRef<number | null>(null);
const scheduleMeasureAndFit = () => {
if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current);
layoutScheduleRef.current = requestAnimationFrame(() => {
updatePanelRects();
layoutScheduleRef.current = requestAnimationFrame(() => {
fitActiveAndNotify();
});
});
};
const hideThenFit = () => {
setReady(false);
requestAnimationFrame(() => {
updatePanelRects();
requestAnimationFrame(() => {
fitActiveAndNotify();
setReady(true);
});
});
};
useEffect(() => {
hideThenFit();
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
useEffect(() => {
scheduleMeasureAndFit();
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
useEffect(() => {
const roContainer = containerRef.current ? new ResizeObserver(() => {
updatePanelRects();
fitActiveAndNotify();
}) : null;
if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
return () => roContainer?.disconnect();
}, []);
useEffect(() => {
const onWinResize = () => {
updatePanelRects();
fitActiveAndNotify();
};
window.addEventListener('resize', onWinResize);
return () => window.removeEventListener('resize', onWinResize);
}, []);
const HEADER_H = 28;
const renderTerminalsLayer = () => {
const styles: Record<number, React.CSSProperties> = {};
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0 && mainTab) {
const isFileManagerTab = mainTab.type === 'file_manager';
styles[mainTab.id] = {
position: 'absolute',
top: isFileManagerTab ? 0 : 2,
left: isFileManagerTab ? 0 : 2,
right: isFileManagerTab ? 0 : 2,
bottom: isFileManagerTab ? 0 : 2,
zIndex: 20,
display: 'block',
pointerEvents: 'auto',
opacity: ready ? 1 : 0
};
} else {
layoutTabs.forEach((t: any) => {
const rect = panelRects[String(t.id)];
const parentRect = containerRef.current?.getBoundingClientRect();
if (rect && parentRect) {
styles[t.id] = {
position: 'absolute',
top: (rect.top - parentRect.top) + HEADER_H + 2,
left: (rect.left - parentRect.left) + 2,
width: rect.width - 4,
height: rect.height - HEADER_H - 4,
zIndex: 20,
display: 'block',
pointerEvents: 'auto',
opacity: ready ? 1 : 0,
};
}
});
}
return (
<div style={{position: 'absolute', inset: 0, zIndex: 1}}>
{terminalTabs.map((t: any) => {
const hasStyle = !!styles[t.id];
const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
const finalStyle: React.CSSProperties = hasStyle
? {...styles[t.id], overflow: 'hidden'}
: {
position: 'absolute', inset: 0, visibility: 'hidden', pointerEvents: 'none', zIndex: 0,
} as React.CSSProperties;
const effectiveVisible = isVisible && ready;
return (
<div key={t.id} style={finalStyle}>
<div className="absolute inset-0 rounded-md bg-[#18181b]">
{t.type === 'terminal' ? (
<Terminal
ref={t.terminalRef}
hostConfig={t.hostConfig}
isVisible={effectiveVisible}
title={t.title}
showTitle={false}
splitScreen={allSplitScreenTab.length > 0}
/>
) : t.type === 'server' ? (
<ServerView
hostConfig={t.hostConfig}
title={t.title}
isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen}
embedded
/>
) : (
<FileManager
embedded
initialHost={t.hostConfig}
/>
)}
</div>
</div>
);
})}
</div>
);
};
const ResetButton = ({onClick}: { onClick: () => void }) => (
<Button
type="button"
variant="ghost"
onClick={onClick}
aria-label="Reset split sizes"
className="absolute top-0 right-0 h-[28px] w-[28px] !rounded-none border-l-1 border-b-1 border-[#222224] bg-[#1b1b1e] hover:bg-[#232327] text-white flex items-center justify-center p-0"
>
<RefreshCcw className="h-4 w-4"/>
</Button>
);
const handleReset = () => {
setResetKey((k) => k + 1);
requestAnimationFrame(() => scheduleMeasureAndFit());
};
const renderSplitOverlays = () => {
const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0) return null;
const handleStyle = {pointerEvents: 'auto', zIndex: 12, background: '#303032'} as React.CSSProperties;
const commonGroupProps = {onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit} as any;
if (layoutTabs.length === 2) {
const [a, b] = layoutTabs as any[];
return (
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal"
className="h-full w-full" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
background: 'transparent',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title}
<ResetButton onClick={handleReset}/>
</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 3) {
const [a, b, c] = layoutTabs as any[];
return (
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id={`panel-${b.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title}
<ResetButton onClick={handleReset}/>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="bottom-panel" order={2}>
<div ref={el => {
panelRefs.current[String(c.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{c.title}</div>
</div>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
if (layoutTabs.length === 4) {
const [a, b, c, d] = layoutTabs as any[];
return (
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
id="main-vertical" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
id="top-panel" order={1}>
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${a.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(a.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{a.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${b.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(b.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>
{b.title}
<ResetButton onClick={handleReset}/>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id="bottom-panel" order={2}>
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal"
className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${c.id}`} order={1}>
<div ref={el => {
panelRefs.current[String(c.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{c.title}</div>
</div>
</ResizablePanel>
<ResizableHandle style={handleStyle}/>
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
id={`panel-${d.id}`} order={2}>
<div ref={el => {
panelRefs.current[String(d.id)] = el;
}} style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative'
}}>
<div style={{
background: '#1b1b1e',
color: '#fff',
fontSize: 13,
height: HEADER_H,
lineHeight: `${HEADER_H}px`,
padding: '0 10px',
borderBottom: '1px solid #222224',
letterSpacing: 1,
margin: 0,
pointerEvents: 'auto',
zIndex: 11,
position: 'relative'
}}>{d.title}</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePrimitive.PanelGroup>
</div>
);
}
return null;
};
const currentTabData = tabs.find((tab: any) => tab.id === currentTab);
const isFileManager = currentTabData?.type === 'file_manager';
const isSplitScreen = allSplitScreenTab.length > 0;
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
const bottomMarginPx = 8;
return (
<div
ref={containerRef}
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
style={{
position: 'relative',
background: (isFileManager && !isSplitScreen) ? '#09090b' : '#18181b',
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
}}
>
{renderTerminalsLayer()}
{renderSplitOverlays()}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import React, {useState} from "react";
import {CardTitle} from "@/components/ui/card.tsx";
import {ChevronDown, Folder} from "lucide-react";
import {Button} from "@/components/ui/button.tsx";
import {Host} from "@/ui/Desktop/Navigation/Hosts/Host.tsx";
import {Separator} from "@/components/ui/separator.tsx";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface FolderCardProps {
folderName: string;
hosts: SSHHost[];
isFirst: boolean;
isLast: boolean;
}
export function FolderCard({folderName, hosts, isFirst, isLast}: FolderCardProps): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true);
const toggleExpanded = () => {
setIsExpanded(!isExpanded);
};
return (
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
style={{padding: '0', margin: '0'}}>
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
<div className="flex gap-2 pr-10">
<div className="flex-shrink-0 flex items-center">
<Folder size={16} strokeWidth={3}/>
</div>
<div className="flex-1 min-w-0">
<CardTitle className="mb-0 leading-tight break-words text-md">{folderName}</CardTitle>
</div>
</div>
<Button
variant="outline"
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0"
onClick={toggleExpanded}
>
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? '' : 'rotate-180'}`}/>
</Button>
</div>
{isExpanded && (
<div className="flex flex-col p-2 gap-y-3">
{hosts.map((host, index) => (
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
<Host host={host}/>
{index < hosts.length - 1 && (
<div className="relative -mx-2">
<Separator className="p-0.25 absolute inset-x-0"/>
</div>
)}
</React.Fragment>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,112 @@
import React, {useEffect, useState} from "react";
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
import {Button} from "@/components/ui/button.tsx";
import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Server, Terminal} from "lucide-react";
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {getServerStatusById} from "@/ui/main-axios.ts";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface HostProps {
host: SSHHost;
}
export function Host({host}: HostProps): React.ReactElement {
const {addTab} = useTabs();
const [serverStatus, setServerStatus] = useState<'online' | 'offline' | 'degraded'>('degraded');
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(() => {
let intervalId: number | undefined;
let cancelled = false;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(host.id);
if (!cancelled) {
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
}
} catch {
if (!cancelled) setServerStatus('offline');
}
};
fetchStatus();
intervalId = window.setInterval(fetchStatus, 10000);
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [host.id]);
const handleTerminalClick = () => {
addTab({type: 'terminal', title, hostConfig: host});
};
const handleServerClick = () => {
addTab({type: 'server', title, hostConfig: host});
};
return (
<div>
<div className="flex items-center gap-2">
<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">
<Button variant="outline" className="!px-2 border-1 border-[#303032]" onClick={handleServerClick}>
<Server/>
</Button>
{host.enableTerminal && (
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={handleTerminalClick}
>
<Terminal/>
</Button>
)}
</ButtonGroup>
</div>
{hasTags && (
<div className="flex flex-wrap items-center gap-2 mt-1">
{tags.map((tag: string) => (
<div key={tag} className="bg-[#18181b] border-1 border-[#303032] pl-2 pr-2 rounded-[10px]">
<p className="text-sm">{tag}</p>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,539 @@
import React, {useState} from 'react';
import {
Computer,
Server,
File,
Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings, Menu, ChevronRight
} from "lucide-react";
import { useTranslation } from 'react-i18next';
import {
Sidebar,
SidebarContent, SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem, SidebarProvider, SidebarInset, SidebarHeader,
} from "@/components/ui/sidebar.tsx"
import {
Separator,
} from "@/components/ui/separator.tsx"
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
SheetClose
} from "@/components/ui/sheet.tsx";
import {Checkbox} from "@/components/ui/checkbox.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Label} from "@/components/ui/label.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import {Card} from "@/components/ui/card.tsx";
import {FolderCard} from "@/ui/Desktop/Navigation/Hosts/FolderCard.tsx";
import {getSSHHosts} from "@/ui/main-axios.ts";
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import { deleteAccount } from "@/ui/main-axios.ts";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
interface SidebarProps {
onSelectView: (view: string) => void;
getView?: () => string;
disabled?: boolean;
isAdmin?: boolean;
username?: string | null;
children?: React.ReactNode;
}
function handleLogout() {
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
window.location.reload();
}
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
export function LeftSidebar({
onSelectView,
getView,
disabled,
isAdmin,
username,
children,
}: SidebarProps): React.ReactElement {
const { t } = useTranslation();
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
const [deletePassword, setDeletePassword] = React.useState("");
const [deleteLoading, setDeleteLoading] = React.useState(false);
const [deleteError, setDeleteError] = React.useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab} = useTabs() as any;
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
const openSshManagerTab = () => {
if (sshManagerTab || isSplitScreenActive) return;
const id = addTab({type: 'ssh_manager'} as any);
setCurrentTab(id);
};
const adminTab = tabList.find((t) => t.type === 'admin');
const openAdminTab = () => {
if (isSplitScreenActive) return;
if (adminTab) {
setCurrentTab(adminTab.id);
return;
}
const id = addTab({type: 'admin'} as any);
setCurrentTab(id);
};
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null);
const prevHostsRef = React.useRef<SSHHost[]>([]);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const fetchHosts = React.useCallback(async () => {
try {
const newHosts = await getSSHHosts();
const prevHosts = prevHostsRef.current;
const existingHostsMap = new Map(prevHosts.map(h => [h.id, h]));
const newHostsMap = new Map(newHosts.map(h => [h.id, h]));
let hasChanges = false;
if (newHosts.length !== prevHosts.length) {
hasChanges = true;
} else {
for (const [id, newHost] of newHostsMap) {
const existingHost = existingHostsMap.get(id);
if (!existingHost) {
hasChanges = true;
break;
}
if (
newHost.name !== existingHost.name ||
newHost.folder !== existingHost.folder ||
newHost.ip !== existingHost.ip ||
newHost.port !== existingHost.port ||
newHost.username !== existingHost.username ||
newHost.pin !== existingHost.pin ||
newHost.enableTerminal !== existingHost.enableTerminal ||
JSON.stringify(newHost.tags) !== JSON.stringify(existingHost.tags)
) {
hasChanges = true;
break;
}
}
}
if (hasChanges) {
setTimeout(() => {
setHosts(newHosts);
prevHostsRef.current = newHosts;
}, 50);
}
} catch (err: any) {
setHostsError(t('leftSidebar.failedToLoadHosts'));
}
}, []);
React.useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 300000); // 5 minutes instead of 10 seconds
return () => clearInterval(interval);
}, [fetchHosts]);
React.useEffect(() => {
const handleHostsChanged = () => {
fetchHosts();
};
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
}, [fetchHosts]);
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();
return hosts.filter(h => {
const searchableText = [
h.name || '',
h.username,
h.ip,
h.folder || '',
...(h.tags || []),
h.authType,
h.defaultPath || ''
].join(' ').toLowerCase();
return searchableText.includes(q);
});
}, [hosts, debouncedSearch]);
const hostsByFolder = React.useMemo(() => {
const map: Record<string, SSHHost[]> = {};
filteredHosts.forEach(h => {
const folder = h.folder && h.folder.trim() ? h.folder : t('leftSidebar.noFolder');
if (!map[folder]) map[folder] = [];
map[folder].push(h);
});
return map;
}, [filteredHosts]);
const sortedFolders = React.useMemo(() => {
const folders = Object.keys(hostsByFolder);
folders.sort((a, b) => {
if (a === t('leftSidebar.noFolder')) return -1;
if (b === t('leftSidebar.noFolder')) return 1;
return a.localeCompare(b);
});
return folders;
}, [hostsByFolder]);
const getSortedHosts = React.useCallback((arr: SSHHost[]) => {
const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
return [...pinned, ...rest];
}, []);
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteLoading(true);
setDeleteError(null);
if (!deletePassword.trim()) {
setDeleteError(t('leftSidebar.passwordRequired'));
setDeleteLoading(false);
return;
}
const jwt = getCookie("jwt");
try {
await deleteAccount(deletePassword);
handleLogout();
} catch (err: any) {
setDeleteError(err?.response?.data?.error || t('leftSidebar.failedToDeleteAccount'));
setDeleteLoading(false);
}
};
return (
<div className="min-h-svh">
<SidebarProvider open={isSidebarOpen}>
<Sidebar variant="floating" className="">
<SidebarHeader>
<SidebarGroupLabel className="text-lg font-bold text-white">
Termix
<Button
variant="outline"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="w-[28px] h-[28px] absolute right-5"
title={t('common.toggleSidebar')}
>
<Menu className="h-4 w-4"/>
</Button>
</SidebarGroupLabel>
</SidebarHeader>
<Separator className="p-0.25"/>
<SidebarContent>
<SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button className="m-2 flex flex-row font-semibold border-2 !border-[#303032]" variant="outline"
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
title={sshManagerTab ? t('interface.sshManagerAlreadyOpen') : isSplitScreenActive ? t('interface.disabledDuringSplitScreen') : undefined}>
<HardDrive strokeWidth="2.5"/>
{t('nav.hostManager')}
</Button>
</SidebarGroup>
<Separator className="p-0.25"/>
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
<div className="!bg-[#222225] rounded-lg">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('placeholders.searchHostsAny')}
className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md"
autoComplete="off"
/>
</div>
{hostsError && (
<div className="px-1">
<div
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
{t('leftSidebar.failedToLoadHosts')}
</div>
</div>
)}
{hostsLoading && (
<div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center">
{t('hosts.loadingHosts')}
</div>
</div>
)}
{sortedFolders.map((folder, idx) => (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
folderName={folder}
hosts={getSortedHosts(hostsByFolder[folder])}
isFirst={idx === 0}
isLast={idx === sortedFolders.length - 1}
/>
))}
</SidebarGroup>
</SidebarContent>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
className="data-[state=open]:opacity-90 w-full"
style={{width: '100%'}}
disabled={disabled}
>
<User2/> {username ? username : t('common.logout')}
<ChevronUp className="ml-auto"/>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
sideOffset={6}
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
>
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => {
if (isSplitScreenActive) return;
const profileTab = tabList.find((t: any) => t.type === 'profile');
if (profileTab) {
setCurrentTab(profileTab.id);
return;
}
const id = addTab({type: 'profile', title: t('profile.title')} as any);
setCurrentTab(id);
}}>
<span>{t('profile.title')}</span>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => {
if (isAdmin) openAdminTab();
}}>
<span>{t('admin.title')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={handleLogout}>
<span>{t('common.logout')}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => setDeleteAccountOpen(true)}
>
<span className="text-red-400">
{t('leftSidebar.deleteAccount')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
{!isSidebarOpen && (
<div
onClick={() => setIsSidebarOpen(true)}
className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md">
<ChevronRight size={10}/>
</div>
)}
{deleteAccountOpen && (
<div
className="fixed inset-0 z-[999999] flex"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 999999,
pointerEvents: 'auto',
isolation: 'isolate',
transform: 'translateZ(0)',
willChange: 'z-index'
}}
>
<div
className="w-[400px] h-full bg-[#18181b] border-r-2 border-[#303032] flex flex-col shadow-2xl"
style={{
backgroundColor: '#18181b',
boxShadow: '4px 0 20px rgba(0, 0, 0, 0.5)',
zIndex: 9999999,
position: 'relative',
isolation: 'isolate',
transform: 'translateZ(0)'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
<h2 className="text-lg font-semibold text-white">{t('leftSidebar.deleteAccount')}</h2>
<Button
variant="outline"
size="sm"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title={t('leftSidebar.closeDeleteAccount')}
>
<span className="text-lg font-bold leading-none">×</span>
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<div className="text-sm text-gray-300">
{t('leftSidebar.deleteAccountWarning')}
</div>
<Alert variant="destructive">
<AlertTitle>{t('common.warning')}</AlertTitle>
<AlertDescription>
{t('leftSidebar.deleteAccountWarningDetails')}
</AlertDescription>
</Alert>
{deleteError && (
<Alert variant="destructive">
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{deleteError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleDeleteAccount} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="delete-password">{t('leftSidebar.confirmPassword')}</Label>
<Input
id="delete-password"
type="password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
placeholder={t('placeholders.confirmPassword')}
required
/>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="destructive"
className="flex-1"
disabled={deleteLoading || !deletePassword.trim()}
>
{deleteLoading ? t('leftSidebar.deleting') : t('leftSidebar.deleteAccount')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
>
{t('leftSidebar.cancel')}
</Button>
</div>
</form>
</div>
</div>
</div>
<div
className="flex-1"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
style={{cursor: 'pointer'}}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,142 @@
import React from "react";
import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Button} from "@/components/ui/button.tsx";
import {useTranslation} from 'react-i18next';
import {
Home,
SeparatorVertical,
X,
Terminal as TerminalIcon,
Server as ServerIcon,
Folder as FolderIcon
} from "lucide-react";
interface TabProps {
tabType: string;
title?: string;
isActive?: boolean;
onActivate?: () => void;
onClose?: () => void;
onSplit?: () => void;
canSplit?: boolean;
canClose?: boolean;
disableActivate?: boolean;
disableSplit?: boolean;
disableClose?: boolean;
}
export function Tab({
tabType,
title,
isActive,
onActivate,
onClose,
onSplit,
canSplit = false,
canClose = false,
disableActivate = false,
disableSplit = false,
disableClose = false
}: TabProps): React.ReactElement {
const {t} = useTranslation();
if (tabType === "home") {
return (
<Button
variant="outline"
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
onClick={onActivate}
disabled={disableActivate}
>
<Home/>
</Button>
);
}
if (tabType === "terminal" || tabType === "server" || tabType === "file_manager") {
const isServer = tabType === 'server';
const isFileManager = tabType === 'file_manager';
return (
<ButtonGroup>
<Button
variant="outline"
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
onClick={onActivate}
disabled={disableActivate}
>
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ?
<FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
{title || (isServer ? t('nav.serverStats') : isFileManager ? t('nav.fileManager') : t('nav.terminal'))}
</Button>
{canSplit && (
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={onSplit}
disabled={disableSplit}
title={disableSplit ? t('nav.cannotSplitTab') : t('nav.splitScreen')}
>
<SeparatorVertical className="w-[28px] h-[28px]"/>
</Button>
)}
{canClose && (
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={onClose}
disabled={disableClose}
>
<X/>
</Button>
)}
</ButtonGroup>
);
}
if (tabType === "ssh_manager") {
return (
<ButtonGroup>
<Button
variant="outline"
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
onClick={onActivate}
disabled={disableActivate}
>
{title || t('nav.sshManager')}
</Button>
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={onClose}
disabled={disableClose}
>
<X/>
</Button>
</ButtonGroup>
);
}
if (tabType === "admin") {
return (
<ButtonGroup>
<Button
variant="outline"
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
onClick={onActivate}
disabled={disableActivate}
>
{title || t('nav.admin')}
</Button>
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={onClose}
disabled={disableClose}
>
<X/>
</Button>
</ButtonGroup>
);
}
return null;
}

View File

@@ -0,0 +1,135 @@
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
import {useTranslation} from 'react-i18next';
export interface Tab {
id: number;
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager';
title: string;
hostConfig?: any;
terminalRef?: React.RefObject<any>;
}
interface TabContextType {
tabs: Tab[];
currentTab: number | null;
allSplitScreenTab: number[];
addTab: (tab: Omit<Tab, 'id'>) => number;
removeTab: (tabId: number) => void;
setCurrentTab: (tabId: number) => void;
setSplitScreenTab: (tabId: number) => void;
getTab: (tabId: number) => Tab | undefined;
}
const TabContext = createContext<TabContextType | undefined>(undefined);
export function useTabs() {
const context = useContext(TabContext);
if (context === undefined) {
throw new Error('useTabs must be used within a TabProvider');
}
return context;
}
interface TabProviderProps {
children: ReactNode;
}
export function TabProvider({children}: TabProviderProps) {
const {t} = useTranslation();
const [tabs, setTabs] = useState<Tab[]>([
{id: 1, type: 'home', title: t('nav.home')}
]);
const [currentTab, setCurrentTab] = useState<number>(1);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const nextTabId = useRef(2);
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
const defaultTitle = tabType === 'server' ? t('nav.serverStats') : (tabType === 'file_manager' ? t('nav.fileManager') : t('nav.terminal'));
const baseTitle = (desiredTitle || defaultTitle).trim();
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
const usedNumbers = new Set<number>();
let rootUsed = false;
tabs.forEach(t => {
if (!t.title) return;
if (t.title === root) {
rootUsed = true;
return;
}
const m = t.title.match(new RegExp(`^${root.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`));
if (m) {
const n = parseInt(m[1], 10);
if (!isNaN(n)) usedNumbers.add(n);
}
});
if (!rootUsed) return root;
let n = 2;
while (usedNumbers.has(n)) n += 1;
return `${root} (${n})`;
}
const addTab = (tabData: Omit<Tab, 'id'>): number => {
const id = nextTabId.current++;
const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server' || tabData.type === 'file_manager';
const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || '');
const newTab: Tab = {
...tabData,
id,
title: effectiveTitle,
terminalRef: tabData.type === 'terminal' ? React.createRef<any>() : undefined
};
setTabs(prev => [...prev, newTab]);
setCurrentTab(id);
setAllSplitScreenTab(prev => prev.filter(tid => tid !== id));
return id;
};
const removeTab = (tabId: number) => {
const tab = tabs.find(t => t.id === tabId);
if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") {
tab.terminalRef.current.disconnect();
}
setTabs(prev => prev.filter(tab => tab.id !== tabId));
setAllSplitScreenTab(prev => prev.filter(id => id !== tabId));
if (currentTab === tabId) {
const remainingTabs = tabs.filter(tab => tab.id !== tabId);
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
}
};
const setSplitScreenTab = (tabId: number) => {
setAllSplitScreenTab(prev => {
if (prev.includes(tabId)) {
return prev.filter(id => id !== tabId);
} else if (prev.length < 3) {
return [...prev, tabId];
}
return prev;
});
};
const getTab = (tabId: number) => {
return tabs.find(tab => tab.id === tabId);
};
const value: TabContextType = {
tabs,
currentTab,
allSplitScreenTab,
addTab,
removeTab,
setCurrentTab,
setSplitScreenTab,
getTab,
};
return (
<TabContext.Provider value={value}>
{children}
</TabContext.Provider>
);
}

View File

@@ -0,0 +1,455 @@
import React, {useState} from "react";
import {useSidebar} from "@/components/ui/sidebar.tsx";
import {Button} from "@/components/ui/button.tsx";
import {ChevronDown, ChevronUpIcon, Hammer} from "lucide-react";
import {Tab} from "@/ui/Desktop/Navigation/Tabs/Tab.tsx";
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Checkbox} from "@/components/ui/checkbox.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {useTranslation} from "react-i18next";
interface TopNavbarProps {
isTopbarOpen: boolean;
setIsTopbarOpen: (open: boolean) => void;
}
export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): React.ReactElement {
const {state} = useSidebar();
const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
const leftPosition = state === "collapsed" ? "26px" : "264px";
const {t} = useTranslation();
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
const handleTabActivate = (tabId: number) => {
setCurrentTab(tabId);
};
const handleTabSplit = (tabId: number) => {
setSplitScreenTab(tabId);
};
const handleTabClose = (tabId: number) => {
removeTab(tabId);
};
const handleTabToggle = (tabId: number) => {
setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]);
};
const handleStartRecording = () => {
setIsRecording(true);
setTimeout(() => {
const input = document.getElementById('ssh-tools-input') as HTMLInputElement;
if (input) input.focus();
}, 100);
};
const handleStopRecording = () => {
setIsRecording(false);
setSelectedTabIds([]);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return;
const value = e.currentTarget.value;
let commandToSend = '';
if (e.ctrlKey || e.metaKey) {
if (e.key === 'c') {
commandToSend = '\x03'; // Ctrl+C (SIGINT)
e.preventDefault();
} else if (e.key === 'd') {
commandToSend = '\x04'; // Ctrl+D (EOF)
e.preventDefault();
} else if (e.key === 'l') {
commandToSend = '\x0c'; // Ctrl+L (clear screen)
e.preventDefault();
} else if (e.key === 'u') {
commandToSend = '\x15'; // Ctrl+U (clear line)
e.preventDefault();
} else if (e.key === 'k') {
commandToSend = '\x0b'; // Ctrl+K (clear from cursor to end)
e.preventDefault();
} else if (e.key === 'a') {
commandToSend = '\x01'; // Ctrl+A (move to beginning of line)
e.preventDefault();
} else if (e.key === 'e') {
commandToSend = '\x05'; // Ctrl+E (move to end of line)
e.preventDefault();
} else if (e.key === 'w') {
commandToSend = '\x17'; // Ctrl+W (delete word before cursor)
e.preventDefault();
}
} else if (e.key === 'Enter') {
commandToSend = '\n';
e.preventDefault();
} else if (e.key === 'Backspace') {
commandToSend = '\x08'; // Backspace
e.preventDefault();
} else if (e.key === 'Delete') {
commandToSend = '\x7f'; // Delete
e.preventDefault();
} else if (e.key === 'Tab') {
commandToSend = '\x09'; // Tab
e.preventDefault();
} else if (e.key === 'Escape') {
commandToSend = '\x1b'; // Escape
e.preventDefault();
} else if (e.key === 'ArrowUp') {
commandToSend = '\x1b[A'; // Up arrow
e.preventDefault();
} else if (e.key === 'ArrowDown') {
commandToSend = '\x1b[B'; // Down arrow
e.preventDefault();
} else if (e.key === 'ArrowLeft') {
commandToSend = '\x1b[D'; // Left arrow
e.preventDefault();
} else if (e.key === 'ArrowRight') {
commandToSend = '\x1b[C'; // Right arrow
e.preventDefault();
} else if (e.key === 'Home') {
commandToSend = '\x1b[H'; // Home
e.preventDefault();
} else if (e.key === 'End') {
commandToSend = '\x1b[F'; // End
e.preventDefault();
} else if (e.key === 'PageUp') {
commandToSend = '\x1b[5~'; // Page Up
e.preventDefault();
} else if (e.key === 'PageDown') {
commandToSend = '\x1b[6~'; // Page Down
e.preventDefault();
} else if (e.key === 'Insert') {
commandToSend = '\x1b[2~'; // Insert
e.preventDefault();
} else if (e.key === 'F1') {
commandToSend = '\x1bOP'; // F1
e.preventDefault();
} else if (e.key === 'F2') {
commandToSend = '\x1bOQ'; // F2
e.preventDefault();
} else if (e.key === 'F3') {
commandToSend = '\x1bOR'; // F3
e.preventDefault();
} else if (e.key === 'F4') {
commandToSend = '\x1bOS'; // F4
e.preventDefault();
} else if (e.key === 'F5') {
commandToSend = '\x1b[15~'; // F5
e.preventDefault();
} else if (e.key === 'F6') {
commandToSend = '\x1b[17~'; // F6
e.preventDefault();
} else if (e.key === 'F7') {
commandToSend = '\x1b[18~'; // F7
e.preventDefault();
} else if (e.key === 'F8') {
commandToSend = '\x1b[19~'; // F8
e.preventDefault();
} else if (e.key === 'F9') {
commandToSend = '\x1b[20~'; // F9
e.preventDefault();
} else if (e.key === 'F10') {
commandToSend = '\x1b[21~'; // F10
e.preventDefault();
} else if (e.key === 'F11') {
commandToSend = '\x1b[23~'; // F11
e.preventDefault();
} else if (e.key === 'F12') {
commandToSend = '\x1b[24~'; // F12
e.preventDefault();
}
if (commandToSend) {
selectedTabIds.forEach(tabId => {
const tab = tabs.find((t: any) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(commandToSend);
}
});
}
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return;
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
const char = e.key;
selectedTabIds.forEach(tabId => {
const tab = tabs.find((t: any) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(char);
}
});
}
};
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const currentTabObj = tabs.find((t: any) => t.id === currentTab);
const currentTabIsHome = currentTabObj?.type === 'home';
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
const currentTabIsAdmin = currentTabObj?.type === 'admin';
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal');
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
const updateRightClickCopyPaste = (checked: boolean) => {
document.cookie = `rightClickCopyPaste=${checked}; expires=2147483647; path=/`;
}
return (
<div>
<div
className="fixed z-10 h-[50px] bg-[#18181b] border-2 border-[#303032] rounded-lg transition-all duration-200 ease-linear flex flex-row"
style={{
top: isTopbarOpen ? "0.5rem" : "-3rem",
left: leftPosition,
right: "17px",
position: "fixed",
transform: "none",
margin: "0",
padding: "0"
}}
>
<div
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
{tabs.map((tab: any) => {
const isActive = tab.id === currentTab;
const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id);
const isTerminal = tab.type === 'terminal';
const isServer = tab.type === 'server';
const isFileManager = tab.type === 'file_manager';
const isSshManager = tab.type === 'ssh_manager';
const isAdmin = tab.type === 'admin';
const isSplittable = isTerminal || isServer || isFileManager;
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
const disableClose = (isSplitScreenActive && isActive) || isSplit;
return (
<Tab
key={tab.id}
tabType={tab.type}
title={tab.title}
isActive={isActive}
onActivate={() => handleTabActivate(tab.id)}
onClose={isTerminal || isServer || isFileManager || isSshManager || isAdmin ? () => handleTabClose(tab.id) : undefined}
onSplit={isSplittable ? () => handleTabSplit(tab.id) : undefined}
canSplit={isSplittable}
canClose={isTerminal || isServer || isFileManager || isSshManager || isAdmin}
disableActivate={disableActivate}
disableSplit={disableSplit}
disableClose={disableClose}
/>
);
})}
</div>
<div className="flex items-center justify-center gap-2 flex-1 px-2">
<Button
variant="outline"
className="w-[30px] h-[30px]"
title={t('nav.tools')}
onClick={() => setToolsSheetOpen(true)}
>
<Hammer className="h-4 w-4"/>
</Button>
<Button
variant="outline"
onClick={() => setIsTopbarOpen(false)}
className="w-[30px] h-[30px]"
>
<ChevronUpIcon/>
</Button>
</div>
</div>
{!isTopbarOpen && (
<div
onClick={() => setIsTopbarOpen(true)}
className="absolute top-0 left-0 w-full h-[10px] bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md">
<ChevronDown size={10}/>
</div>
)}
{toolsSheetOpen && (
<div
className="fixed inset-0 z-[999999] flex justify-end"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 999999,
pointerEvents: 'auto',
isolation: 'isolate',
transform: 'translateZ(0)'
}}
>
<div
className="flex-1"
onClick={() => setToolsSheetOpen(false)}
style={{cursor: 'pointer'}}
/>
<div
className="w-[400px] h-full bg-[#18181b] border-l-2 border-[#303032] flex flex-col shadow-2xl"
style={{
backgroundColor: '#18181b',
boxShadow: '-4px 0 20px rgba(0, 0, 0, 0.5)',
zIndex: 999999,
position: 'relative',
isolation: 'isolate',
transform: 'translateZ(0)'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
<h2 className="text-lg font-semibold text-white">{t('sshTools.title')}</h2>
<Button
variant="outline"
size="sm"
onClick={() => setToolsSheetOpen(false)}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title={t('sshTools.closeTools')}
>
<span className="text-lg font-bold leading-none">×</span>
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<h1 className="font-semibold">
{t('sshTools.keyRecording')}
</h1>
<div className="space-y-4">
<div className="space-y-4">
<div className="flex gap-2">
{!isRecording ? (
<Button
onClick={handleStartRecording}
className="flex-1"
variant="outline"
>
{t('sshTools.startKeyRecording')}
</Button>
) : (
<Button
onClick={handleStopRecording}
className="flex-1"
variant="destructive"
>
{t('sshTools.stopKeyRecording')}
</Button>
)}
</div>
{isRecording && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">{t('sshTools.selectTerminals')}</label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
{terminalTabs.map(tab => (
<Button
key={tab.id}
type="button"
variant="outline"
size="sm"
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
selectedTabIds.includes(tab.id)
? 'text-white bg-gray-700'
: 'text-gray-500'
}`}
onClick={() => handleTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">{t('sshTools.typeCommands')}</label>
<Input
id="ssh-tools-input"
placeholder={t('placeholders.typeHere')}
onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress}
className="font-mono mt-2"
disabled={selectedTabIds.length === 0}
readOnly
/>
<p className="text-xs text-muted-foreground">
{t('sshTools.commandsWillBeSent', { count: selectedTabIds.length })}
</p>
</div>
</>
)}
</div>
</div>
<Separator className="my-4"/>
<h1 className="font-semibold">
{t('sshTools.settings')}
</h1>
<div className="flex items-center space-x-2">
<Checkbox
id="enable-copy-paste"
onCheckedChange={updateRightClickCopyPaste}
defaultChecked={getCookie("rightClickCopyPaste") === "true"}
/>
<label
htmlFor="enable-copy-paste"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-white"
>
{t('sshTools.enableRightClickCopyPaste')}
</label>
</div>
<Separator className="my-4"/>
<p className="pt-2 pb-2 text-sm text-gray-500">
{t('sshTools.shareIdeas')}{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,243 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card.tsx";
import {Key} from "lucide-react";
import React, {useState} from "react";
import {completePasswordReset, initiatePasswordReset, verifyPasswordResetCode} from "@/ui/main-axios.ts";
import {Label} from "@/components/ui/label.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {toast} from "sonner";
import {useTranslation} from "react-i18next";
interface PasswordResetProps {
userInfo: {
username: string;
is_admin: boolean;
is_oidc: boolean;
totp_enabled: boolean;
}
}
export function PasswordReset({userInfo}: PasswordResetProps) {
const {t} = useTranslation();
const [error, setError] = useState<string | null>(null);
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
const [resetCode, setResetCode] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [tempToken, setTempToken] = useState("");
const [resetLoading, setResetLoading] = useState(false);
async function handleInitiatePasswordReset() {
setError(null);
setResetLoading(true);
try {
const result = await initiatePasswordReset(userInfo.username);
setResetStep("verify");
setError(null);
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || t('common.failedToInitiatePasswordReset'));
} finally {
setResetLoading(false);
}
}
function resetPasswordState() {
setResetStep("initiate");
setResetCode("");
setNewPassword("");
setConfirmPassword("");
setTempToken("");
setError(null);
}
async function handleVerifyResetCode() {
setError(null);
setResetLoading(true);
try {
const response = await verifyPasswordResetCode(userInfo.username, resetCode);
setTempToken(response.tempToken);
setResetStep("newPassword");
setError(null);
} catch (err: any) {
setError(err?.response?.data?.error || t('common.failedToVerifyResetCode'));
} finally {
setResetLoading(false);
}
}
async function handleCompletePasswordReset() {
setError(null);
setResetLoading(true);
if (newPassword !== confirmPassword) {
setError(t('common.passwordsDoNotMatch'));
setResetLoading(false);
return;
}
if (newPassword.length < 6) {
setError(t('common.passwordMinLength'));
setResetLoading(false);
return;
}
try {
await completePasswordReset(userInfo.username, tempToken, newPassword);
toast.success(t('common.passwordResetSuccess'));
resetPasswordState();
} catch (err: any) {
setError(err?.response?.data?.error || t('common.failedToCompletePasswordReset'));
} finally {
setResetLoading(false);
}
}
const Spinner = (
<svg className="animate-spin mr-2 h-4 w-4 text-white 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>
<>
{resetStep === "initiate" && (
<>
<div className="flex flex-col gap-4">
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !userInfo.username.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : t('common.sendResetCode')}
</Button>
</div>
</>
)}
{resetStep === "verify" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t('common.enterSixDigitCode')} <strong>{userInfo.username}</strong></p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">{t('common.resetCode')}</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
disabled={resetLoading}
placeholder={t('placeholders.enterCode')}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode}
>
{resetLoading ? Spinner : t('common.verifyCode')}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
{t('common.back')}
</Button>
</div>
</>
)}
{resetStep === "newPassword" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>{t('common.enterNewPassword')} <strong>{userInfo.username}</strong></p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">{t('common.newPassword')}</Label>
<Input
id="new-password"
type="password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">{t('common.confirmPassword')}</Label>
<Input
id="confirm-password"
type="password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !newPassword || !confirmPassword}
onClick={handleCompletePasswordReset}
>
{resetLoading ? Spinner : t('common.resetPassword')}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
{t('common.back')}
</Button>
</div>
</>
)}
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,439 @@
import React, { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx";
import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react";
import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/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: any) {
setError(err?.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: any) {
setError(err?.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: any) {
setError(err?.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: any) {
setError(err?.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>
<Input
id="disable-password"
type="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>
<Input
id="backup-password"
type="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>
{t('auth.addExtraSecurityLayer')}
</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">
{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>
);
}

View File

@@ -0,0 +1,179 @@
import React, {useState, useEffect} from "react";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Label} from "@/components/ui/label.tsx";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {User, Shield, Key, AlertCircle} from "lucide-react";
import {TOTPSetup} from "@/ui/Desktop/User/TOTPSetup.tsx";
import {getUserInfo} from "@/ui/main-axios.ts";
import {toast} from "sonner";
import {PasswordReset} from "@/ui/Desktop/User/PasswordReset.tsx";
import {useTranslation} from "react-i18next";
import {LanguageSwitcher} from "@/components/LanguageSwitcher.tsx";
interface UserProfileProps {
isTopbarOpen?: boolean;
}
export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
const {t} = useTranslation();
const [userInfo, setUserInfo] = useState<{
username: string;
is_admin: boolean;
is_oidc: boolean;
totp_enabled: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchUserInfo();
}, []);
const fetchUserInfo = async () => {
setLoading(true);
setError(null);
try {
const info = await getUserInfo();
setUserInfo({
username: info.username,
is_admin: info.is_admin,
is_oidc: info.is_oidc,
totp_enabled: info.totp_enabled || false
});
} catch (err: any) {
setError(err?.response?.data?.error || t('errors.loadFailed'));
} finally {
setLoading(false);
}
};
const handleTOTPStatusChange = (enabled: boolean) => {
if (userInfo) {
setUserInfo({...userInfo, totp_enabled: enabled});
}
};
if (loading) {
return (
<div className="container max-w-4xl mx-auto p-6">
<Card>
<CardContent className="p-12 text-center">
<div className="animate-pulse">{t('common.loading')}</div>
</CardContent>
</Card>
</div>
);
}
if (error || !userInfo) {
return (
<div className="container max-w-4xl mx-auto p-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4"/>
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{error || t('errors.loadFailed')}</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="container max-w-4xl mx-auto p-6 overflow-y-auto" style={{
marginTop: isTopbarOpen ? '60px' : '0',
transition: 'margin-top 0.3s ease',
maxHeight: 'calc(100vh - 60px)'
}}>
<div className="mb-6">
<h1 className="text-3xl font-bold">{t('common.profile')}</h1>
<p className="text-muted-foreground mt-2">{t('profile.description')}</p>
</div>
<Tabs defaultValue="profile" className="space-y-4">
<TabsList>
<TabsTrigger value="profile" className="flex items-center gap-2">
<User className="w-4 h-4"/>
{t('common.profile')}
</TabsTrigger>
{!userInfo.is_oidc && (
<TabsTrigger value="security" className="flex items-center gap-2">
<Shield className="w-4 h-4"/>
{t('profile.security')}
</TabsTrigger>
)}
</TabsList>
<TabsContent value="profile" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{t('profile.accountInfo')}</CardTitle>
<CardDescription>{t('profile.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>{t('common.username')}</Label>
<p className="text-lg font-medium mt-1">{userInfo.username}</p>
</div>
<div>
<Label>{t('profile.role')}</Label>
<p className="text-lg font-medium mt-1">
{userInfo.is_admin ? t('interface.administrator') : t('interface.user')}
</p>
</div>
<div>
<Label>{t('profile.authMethod')}</Label>
<p className="text-lg font-medium mt-1">
{userInfo.is_oidc ? t('profile.external') : t('profile.local')}
</p>
</div>
<div>
<Label>{t('profile.twoFactorAuth')}</Label>
<p className="text-lg font-medium mt-1">
{userInfo.is_oidc ? (
<span className="text-muted-foreground">{t('auth.lockedOidcAuth')}</span>
) : (
userInfo.totp_enabled ? (
<span className="text-green-600 flex items-center gap-1">
<Shield className="w-4 h-4"/>
{t('common.enabled')}
</span>
) : (
<span className="text-muted-foreground">{t('common.disabled')}</span>
)
)}
</p>
</div>
</div>
<div className="mt-6 pt-6 border-t">
<div className="flex items-center justify-between">
<div>
<Label>{t('common.language')}</Label>
<p className="text-sm text-muted-foreground mt-1">{t('profile.selectPreferredLanguage')}</p>
</div>
<LanguageSwitcher />
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="security" className="space-y-4">
<TOTPSetup
isEnabled={userInfo.totp_enabled}
onStatusChange={handleTOTPStatusChange}
/>
{!userInfo.is_oidc && (
<PasswordReset
userInfo={userInfo}
/>
)}
</TabsContent>
</Tabs>
</div>
);
}