Initial scaffold: full-stack mRemotify monorepo
Sets up the complete mRemotify project — a browser-based remote connection manager — with a working pnpm workspace monorepo: Frontend (React + TypeScript + Vite + Ant Design 5): - Login page with JWT auth - Resizable sidebar with drag-and-drop connection tree (folders + connections) - Tabbed session area (SSH via xterm.js, RDP via guacamole-common-js) - Connection CRUD modal with SSH/RDP-specific fields - Zustand store for auth, tree data, and open sessions Backend (Fastify + TypeScript + Prisma + PostgreSQL): - JWT authentication (login + /me endpoint) - Full CRUD REST API for folders (self-referencing) and connections - AES-256-CBC password encryption at rest - WebSocket proxy for SSH sessions (ssh2 <-> xterm.js) - WebSocket proxy for RDP sessions (guacd TCP handshake + bidirectional relay) - Admin user seeding on first start Infrastructure: - Docker Compose: postgres (healthcheck) + guacd + backend + frontend/nginx - nginx: serves SPA, proxies /api and /ws (with WebSocket upgrade) to backend - .env.example with all required variables documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
25
frontend/src/App.tsx
Normal file
25
frontend/src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { ConfigProvider, theme } from 'antd';
|
||||
import { useStore } from './store';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { MainLayout } from './components/Layout/MainLayout';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const token = useStore((s) => s.token);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
borderRadius: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{token ? <MainLayout /> : <LoginPage />}
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
36
frontend/src/api/client.ts
Normal file
36
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import axios from 'axios';
|
||||
import { Connection, ConnectionFormValues, Folder, User } from '../types';
|
||||
|
||||
const api = axios.create({ baseURL: '/api' });
|
||||
|
||||
// Attach JWT token to every request
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
// Auth
|
||||
export const apiLogin = (username: string, password: string) =>
|
||||
api.post<{ token: string; user: User }>('/auth/login', { username, password });
|
||||
|
||||
export const apiMe = () => api.get<User>('/auth/me');
|
||||
|
||||
// Folders
|
||||
export const apiFolderList = () => api.get<Folder[]>('/folders');
|
||||
export const apiFolderCreate = (data: { name: string; parentId?: string | null }) =>
|
||||
api.post<Folder>('/folders', data);
|
||||
export const apiFolderUpdate = (id: string, data: { name?: string; parentId?: string | null }) =>
|
||||
api.patch<Folder>(`/folders/${id}`, data);
|
||||
export const apiFolderDelete = (id: string) => api.delete(`/folders/${id}`);
|
||||
|
||||
// Connections
|
||||
export const apiConnectionList = () => api.get<Connection[]>('/connections');
|
||||
export const apiConnectionGet = (id: string) => api.get<Connection>(`/connections/${id}`);
|
||||
export const apiConnectionCreate = (data: ConnectionFormValues) =>
|
||||
api.post<Connection>('/connections', data);
|
||||
export const apiConnectionUpdate = (id: string, data: Partial<ConnectionFormValues>) =>
|
||||
api.patch<Connection>(`/connections/${id}`, data);
|
||||
export const apiConnectionDelete = (id: string) => api.delete(`/connections/${id}`);
|
||||
|
||||
export default api;
|
||||
95
frontend/src/components/Layout/MainLayout.tsx
Normal file
95
frontend/src/components/Layout/MainLayout.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Layout } from 'antd';
|
||||
import { TopNav } from '../Nav/TopNav';
|
||||
import { ConnectionTree } from '../Sidebar/ConnectionTree';
|
||||
import { SessionTabs } from '../Tabs/SessionTabs';
|
||||
|
||||
const { Content } = Layout;
|
||||
|
||||
const MIN_SIDEBAR = 180;
|
||||
const MAX_SIDEBAR = 600;
|
||||
const DEFAULT_SIDEBAR = 260;
|
||||
|
||||
export const MainLayout: React.FC = () => {
|
||||
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR);
|
||||
const isResizing = useRef(false);
|
||||
|
||||
const onMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isResizing.current = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMouseMove = (me: MouseEvent) => {
|
||||
if (!isResizing.current) return;
|
||||
const newWidth = Math.min(MAX_SIDEBAR, Math.max(MIN_SIDEBAR, me.clientX));
|
||||
setSidebarWidth(newWidth);
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isResizing.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||
<TopNav />
|
||||
|
||||
<Layout style={{ flex: 1, overflow: 'hidden' }}>
|
||||
{/* Resizable sidebar */}
|
||||
<div
|
||||
style={{
|
||||
width: sidebarWidth,
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid #f0f0f0',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<ConnectionTree />
|
||||
</div>
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
style={{
|
||||
width: 4,
|
||||
cursor: 'col-resize',
|
||||
background: 'transparent',
|
||||
flexShrink: 0,
|
||||
zIndex: 10,
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLDivElement).style.background = '#1677ff40';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isResizing.current)
|
||||
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<Content
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<SessionTabs />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
190
frontend/src/components/Modals/ConnectionModal.tsx
Normal file
190
frontend/src/components/Modals/ConnectionModal.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Button,
|
||||
Space,
|
||||
Divider,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd';
|
||||
import { Connection, ConnectionFormValues, Folder } from '../../types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
connection?: Connection | null;
|
||||
folders: Folder[];
|
||||
onClose: () => void;
|
||||
onSave: (values: ConnectionFormValues, id?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
export const ConnectionModal: React.FC<Props> = ({
|
||||
open,
|
||||
connection,
|
||||
folders,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const [form] = Form.useForm<ConnectionFormValues>();
|
||||
const protocol = Form.useWatch('protocol', form);
|
||||
const isEdit = !!connection;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (connection) {
|
||||
form.setFieldsValue({
|
||||
name: connection.name,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
protocol: connection.protocol,
|
||||
username: connection.username,
|
||||
privateKey: connection.privateKey ?? undefined,
|
||||
domain: connection.domain ?? undefined,
|
||||
osType: connection.osType ?? undefined,
|
||||
notes: connection.notes ?? undefined,
|
||||
folderId: connection.folderId ?? null,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ protocol: 'ssh', port: 22 });
|
||||
}
|
||||
}
|
||||
}, [open, connection, form]);
|
||||
|
||||
const handleProtocolChange = (value: 'ssh' | 'rdp') => {
|
||||
form.setFieldValue('port', value === 'ssh' ? 22 : 3389);
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
const values = await form.validateFields();
|
||||
await onSave(values, connection?.id);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
// Build flat folder options with visual indentation
|
||||
const buildFolderOptions = (
|
||||
allFolders: Folder[],
|
||||
parentId: string | null = null,
|
||||
depth = 0
|
||||
): React.ReactNode[] => {
|
||||
return allFolders
|
||||
.filter((f) => f.parentId === parentId)
|
||||
.flatMap((f) => [
|
||||
<Option key={f.id} value={f.id}>
|
||||
{'\u00a0\u00a0'.repeat(depth)}
|
||||
{depth > 0 ? '└ ' : ''}
|
||||
{f.name}
|
||||
</Option>,
|
||||
...buildFolderOptions(allFolders, f.id, depth + 1),
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? `Edit — ${connection?.name}` : 'New Connection'}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={520}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button type="primary" onClick={handleOk}>
|
||||
{isEdit ? 'Save' : 'Create'}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark="optional" style={{ marginTop: 8 }}>
|
||||
<Form.Item label="Name" name="name" rules={[{ required: true, message: 'Required' }]}>
|
||||
<Input placeholder="My Server" autoFocus />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={8}>
|
||||
<Col flex={1}>
|
||||
<Form.Item
|
||||
label="Host"
|
||||
name="host"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
>
|
||||
<Input placeholder="192.168.1.1" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col style={{ width: 110 }}>
|
||||
<Form.Item
|
||||
label="Port"
|
||||
name="port"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
>
|
||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
label="Protocol"
|
||||
name="protocol"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
>
|
||||
<Select onChange={handleProtocolChange}>
|
||||
<Option value="ssh">SSH</Option>
|
||||
<Option value="rdp">RDP</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Username"
|
||||
name="username"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
>
|
||||
<Input placeholder="root" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Password"
|
||||
name="password"
|
||||
extra={isEdit ? 'Leave blank to keep the current password' : undefined}
|
||||
>
|
||||
<Input.Password placeholder="••••••••" autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
|
||||
{protocol === 'ssh' && (
|
||||
<Form.Item label="Private Key" name="privateKey" extra="PEM-formatted SSH private key">
|
||||
<TextArea rows={4} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{protocol === 'rdp' && (
|
||||
<Form.Item label="Domain" name="domain">
|
||||
<Input placeholder="CORP" />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
<Form.Item label="OS Type" name="osType">
|
||||
<Select allowClear placeholder="Select OS (optional)">
|
||||
<Option value="linux">Linux</Option>
|
||||
<Option value="windows">Windows</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Folder" name="folderId">
|
||||
<Select allowClear placeholder="Root (no folder)">
|
||||
{buildFolderOptions(folders)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Notes" name="notes">
|
||||
<TextArea rows={3} placeholder="Optional notes…" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
46
frontend/src/components/Nav/TopNav.tsx
Normal file
46
frontend/src/components/Nav/TopNav.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Layout, Typography, Dropdown, Avatar, Space } from 'antd';
|
||||
import { UserOutlined, LogoutOutlined } from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useStore } from '../../store';
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
export const TopNav: React.FC = () => {
|
||||
const user = useStore((s) => s.user);
|
||||
const logout = useStore((s) => s.logout);
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: 'Sign out',
|
||||
onClick: logout,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 16px',
|
||||
background: '#001529',
|
||||
height: 48,
|
||||
lineHeight: '48px',
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ color: '#fff', fontSize: 16 }}>
|
||||
mRemotify
|
||||
</Typography.Text>
|
||||
|
||||
<Dropdown menu={{ items: menuItems }} placement="bottomRight" arrow>
|
||||
<Space style={{ cursor: 'pointer', color: '#fff' }}>
|
||||
<Avatar size="small" icon={<UserOutlined />} />
|
||||
<Typography.Text style={{ color: '#fff' }}>{user?.username}</Typography.Text>
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
373
frontend/src/components/Sidebar/ConnectionTree.tsx
Normal file
373
frontend/src/components/Sidebar/ConnectionTree.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Tree,
|
||||
Button,
|
||||
Dropdown,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Input,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { DataNode, DirectoryTreeProps } from 'antd/es/tree';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
FolderOutlined,
|
||||
FolderOpenOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
WindowsOutlined,
|
||||
CodeOutlined,
|
||||
MoreOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
FolderAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useStore } from '../../store';
|
||||
import {
|
||||
apiFolderList,
|
||||
apiFolderCreate,
|
||||
apiFolderDelete,
|
||||
apiConnectionList,
|
||||
apiConnectionCreate,
|
||||
apiConnectionDelete,
|
||||
apiConnectionUpdate,
|
||||
} from '../../api/client';
|
||||
import { Connection, ConnectionFormValues, Folder } from '../../types';
|
||||
import { ConnectionModal } from '../Modals/ConnectionModal';
|
||||
|
||||
interface TreeItemData {
|
||||
type: 'folder' | 'connection';
|
||||
folder?: Folder;
|
||||
connection?: Connection;
|
||||
}
|
||||
|
||||
interface ExtendedDataNode extends DataNode {
|
||||
itemData: TreeItemData;
|
||||
children?: ExtendedDataNode[];
|
||||
}
|
||||
|
||||
const OsIcon: React.FC<{ osType?: string | null }> = ({ osType }) => {
|
||||
if (osType === 'windows') return <WindowsOutlined />;
|
||||
return <CodeOutlined />;
|
||||
};
|
||||
|
||||
function buildTree(
|
||||
folders: Folder[],
|
||||
connections: Connection[],
|
||||
parentId: string | null = null
|
||||
): ExtendedDataNode[] {
|
||||
const subFolders: ExtendedDataNode[] = folders
|
||||
.filter((f) => f.parentId === parentId)
|
||||
.map((folder) => ({
|
||||
key: `folder-${folder.id}`,
|
||||
title: folder.name,
|
||||
isLeaf: false,
|
||||
itemData: { type: 'folder' as const, folder },
|
||||
children: buildTree(folders, connections, folder.id),
|
||||
}));
|
||||
|
||||
const leafConnections: ExtendedDataNode[] = connections
|
||||
.filter((c) => c.folderId === parentId)
|
||||
.map((connection) => ({
|
||||
key: `connection-${connection.id}`,
|
||||
title: connection.name,
|
||||
isLeaf: true,
|
||||
icon: <OsIcon osType={connection.osType} />,
|
||||
itemData: { type: 'connection' as const, connection },
|
||||
}));
|
||||
|
||||
return [...subFolders, ...leafConnections];
|
||||
}
|
||||
|
||||
export const ConnectionTree: React.FC = () => {
|
||||
const folders = useStore((s) => s.folders);
|
||||
const connections = useStore((s) => s.connections);
|
||||
const setFolders = useStore((s) => s.setFolders);
|
||||
const setConnections = useStore((s) => s.setConnections);
|
||||
const openSession = useStore((s) => s.openSession);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
|
||||
const [newFolderParentId, setNewFolderParentId] = useState<string | null>(null);
|
||||
const [addingFolder, setAddingFolder] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [fRes, cRes] = await Promise.all([apiFolderList(), apiConnectionList()]);
|
||||
setFolders(fRes.data);
|
||||
setConnections(cRes.data);
|
||||
} catch {
|
||||
message.error('Failed to load connections');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setFolders, setConnections]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const treeData = buildTree(folders, connections);
|
||||
|
||||
// --- Drop: move connection into folder ---
|
||||
const onDrop: DirectoryTreeProps['onDrop'] = async (info) => {
|
||||
const dragNode = info.dragNode as unknown as ExtendedDataNode;
|
||||
const dropNode = info.node as unknown as ExtendedDataNode;
|
||||
if (dragNode.itemData.type !== 'connection') return;
|
||||
|
||||
const connectionId = dragNode.itemData.connection!.id;
|
||||
let targetFolderId: string | null = null;
|
||||
|
||||
if (dropNode.itemData.type === 'folder') {
|
||||
targetFolderId = dropNode.itemData.folder!.id;
|
||||
} else if (dropNode.itemData.type === 'connection') {
|
||||
targetFolderId = dropNode.itemData.connection!.folderId ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiConnectionUpdate(connectionId, { folderId: targetFolderId });
|
||||
await refresh();
|
||||
} catch {
|
||||
message.error('Failed to move connection');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Double-click: open session ---
|
||||
const onDoubleClick = (_: React.MouseEvent, node: DataNode) => {
|
||||
const ext = node as ExtendedDataNode;
|
||||
if (ext.itemData.type === 'connection') {
|
||||
openSession(ext.itemData.connection!);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Context menu items ---
|
||||
const getContextMenu = (node: ExtendedDataNode): MenuProps['items'] => {
|
||||
if (node.itemData.type === 'connection') {
|
||||
return [
|
||||
{
|
||||
key: 'connect',
|
||||
label: 'Connect',
|
||||
onClick: () => openSession(node.itemData.connection!),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
label: 'Edit',
|
||||
onClick: () => {
|
||||
setEditingConnection(node.itemData.connection!);
|
||||
setModalOpen(true);
|
||||
},
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'delete',
|
||||
icon: <DeleteOutlined />,
|
||||
label: 'Delete',
|
||||
danger: true,
|
||||
onClick: async () => {
|
||||
try {
|
||||
await apiConnectionDelete(node.itemData.connection!.id);
|
||||
await refresh();
|
||||
} catch {
|
||||
message.error('Failed to delete connection');
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'addConnection',
|
||||
icon: <PlusOutlined />,
|
||||
label: 'Add connection here',
|
||||
onClick: () => {
|
||||
setEditingConnection(null);
|
||||
setModalOpen(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'addSubfolder',
|
||||
icon: <FolderAddOutlined />,
|
||||
label: 'Add subfolder',
|
||||
onClick: () => {
|
||||
setNewFolderParentId(node.itemData.folder!.id);
|
||||
setAddingFolder(true);
|
||||
},
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'deleteFolder',
|
||||
icon: <DeleteOutlined />,
|
||||
label: 'Delete folder',
|
||||
danger: true,
|
||||
onClick: async () => {
|
||||
try {
|
||||
await apiFolderDelete(node.itemData.folder!.id);
|
||||
await refresh();
|
||||
} catch {
|
||||
message.error('Failed to delete folder');
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// --- Save connection (create or update) ---
|
||||
const handleSave = async (values: ConnectionFormValues, id?: string) => {
|
||||
try {
|
||||
if (id) {
|
||||
await apiConnectionUpdate(id, values);
|
||||
} else {
|
||||
await apiConnectionCreate(values);
|
||||
}
|
||||
setModalOpen(false);
|
||||
setEditingConnection(null);
|
||||
await refresh();
|
||||
} catch {
|
||||
message.error('Failed to save connection');
|
||||
}
|
||||
};
|
||||
|
||||
// --- Add folder ---
|
||||
const commitNewFolder = async () => {
|
||||
if (!newFolderName.trim()) {
|
||||
setAddingFolder(false);
|
||||
setNewFolderName('');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiFolderCreate({ name: newFolderName.trim(), parentId: newFolderParentId });
|
||||
await refresh();
|
||||
} catch {
|
||||
message.error('Failed to create folder');
|
||||
} finally {
|
||||
setAddingFolder(false);
|
||||
setNewFolderName('');
|
||||
setNewFolderParentId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const titleRender = (node: DataNode) => {
|
||||
const ext = node as ExtendedDataNode;
|
||||
return (
|
||||
<Dropdown menu={{ items: getContextMenu(ext) }} trigger={['contextMenu']}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, width: '100%' }}>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{String(node.title)}
|
||||
</span>
|
||||
<Dropdown menu={{ items: getContextMenu(ext) }} trigger={['click']}>
|
||||
<MoreOutlined
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ opacity: 0.45, fontSize: 12 }}
|
||||
/>
|
||||
</Dropdown>
|
||||
</span>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 8px 4px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="New connection">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingConnection(null);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="New folder">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FolderAddOutlined />}
|
||||
onClick={() => {
|
||||
setNewFolderParentId(null);
|
||||
setAddingFolder(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Refresh">
|
||||
<Button size="small" icon={<ReloadOutlined />} loading={loading} onClick={refresh} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Inline folder name input */}
|
||||
{addingFolder && (
|
||||
<div style={{ padding: '4px 8px' }}>
|
||||
<Input
|
||||
size="small"
|
||||
autoFocus
|
||||
placeholder="Folder name"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onPressEnter={commitNewFolder}
|
||||
onBlur={commitNewFolder}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tree */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '4px 0' }}>
|
||||
{treeData.length === 0 && !loading ? (
|
||||
<Typography.Text type="secondary" style={{ padding: '8px 16px', display: 'block' }}>
|
||||
No connections yet. Click + to add one.
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
draggable={{ icon: false }}
|
||||
blockNode
|
||||
showIcon
|
||||
onDrop={onDrop}
|
||||
onDoubleClick={onDoubleClick}
|
||||
titleRender={titleRender}
|
||||
icon={(node) => {
|
||||
const ext = node as unknown as ExtendedDataNode;
|
||||
if (ext.itemData?.type === 'folder') {
|
||||
return (node as { expanded?: boolean }).expanded ? (
|
||||
<FolderOpenOutlined />
|
||||
) : (
|
||||
<FolderOutlined />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConnectionModal
|
||||
open={modalOpen}
|
||||
connection={editingConnection}
|
||||
folders={folders}
|
||||
onClose={() => {
|
||||
setModalOpen(false);
|
||||
setEditingConnection(null);
|
||||
}}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
95
frontend/src/components/Tabs/RdpTab.tsx
Normal file
95
frontend/src/components/Tabs/RdpTab.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Guacamole from 'guacamole-common-js';
|
||||
import { useStore } from '../../store';
|
||||
import { Session } from '../../types';
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
}
|
||||
|
||||
function getWsUrl(connectionId: string, token: string): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
return `${proto}//${host}/ws/rdp/${connectionId}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export const RdpTab: React.FC<Props> = ({ session }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const token = useStore((s) => s.token) ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const url = getWsUrl(session.connection.id, token);
|
||||
const tunnel = new Guacamole.WebSocketTunnel(url);
|
||||
const client = new Guacamole.Client(tunnel);
|
||||
|
||||
// Mount the Guacamole display canvas
|
||||
const displayEl = client.getDisplay().getElement();
|
||||
displayEl.style.cursor = 'default';
|
||||
container.appendChild(displayEl);
|
||||
|
||||
// Mouse input — forward all mouse events to guacd
|
||||
const mouse = new Guacamole.Mouse(displayEl);
|
||||
const sendMouse = (mouseState: Guacamole.Mouse.State) =>
|
||||
client.sendMouseState(mouseState, true);
|
||||
mouse.onmousedown = sendMouse;
|
||||
mouse.onmouseup = sendMouse;
|
||||
mouse.onmousemove = sendMouse;
|
||||
|
||||
// Keyboard input
|
||||
const keyboard = new Guacamole.Keyboard(document);
|
||||
keyboard.onkeydown = (keysym: number) => client.sendKeyEvent(1, keysym);
|
||||
keyboard.onkeyup = (keysym: number) => client.sendKeyEvent(0, keysym);
|
||||
|
||||
// Scale display to fit container
|
||||
const fitDisplay = () => {
|
||||
const display = client.getDisplay();
|
||||
if (!container || display.getWidth() === 0) return;
|
||||
const scaleX = container.clientWidth / display.getWidth();
|
||||
const scaleY = container.clientHeight / display.getHeight();
|
||||
display.scale(Math.min(scaleX, scaleY));
|
||||
};
|
||||
|
||||
client.getDisplay().onresize = fitDisplay;
|
||||
const resizeObserver = new ResizeObserver(fitDisplay);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Connect
|
||||
client.connect();
|
||||
|
||||
tunnel.onerror = (status: Guacamole.Status) => {
|
||||
console.error('Guacamole tunnel error:', status.message);
|
||||
};
|
||||
|
||||
client.onerror = (error: Guacamole.Status) => {
|
||||
console.error('Guacamole client error:', error.message);
|
||||
};
|
||||
|
||||
return () => {
|
||||
keyboard.onkeydown = null;
|
||||
keyboard.onkeyup = null;
|
||||
resizeObserver.disconnect();
|
||||
client.disconnect();
|
||||
if (container.contains(displayEl)) {
|
||||
container.removeChild(displayEl);
|
||||
}
|
||||
};
|
||||
}, [session.connection.id, token]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
background: '#000',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
81
frontend/src/components/Tabs/SessionTabs.tsx
Normal file
81
frontend/src/components/Tabs/SessionTabs.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { Tabs, Typography } from 'antd';
|
||||
import { CodeOutlined, WindowsOutlined } from '@ant-design/icons';
|
||||
import { useStore } from '../../store';
|
||||
import { SshTab } from './SshTab';
|
||||
import { RdpTab } from './RdpTab';
|
||||
import { Session } from '../../types';
|
||||
|
||||
function tabLabel(session: Session) {
|
||||
const icon =
|
||||
session.connection.protocol === 'rdp' ? (
|
||||
<WindowsOutlined />
|
||||
) : (
|
||||
<CodeOutlined />
|
||||
);
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{icon}
|
||||
{session.connection.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const SessionTabs: React.FC = () => {
|
||||
const sessions = useStore((s) => s.sessions);
|
||||
const activeSessionId = useStore((s) => s.activeSessionId);
|
||||
const closeSession = useStore((s) => s.closeSession);
|
||||
const setActiveSession = useStore((s) => s.setActiveSession);
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#bfbfbf',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} type="secondary" style={{ marginBottom: 8 }}>
|
||||
No open sessions
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
Double-click a connection in the sidebar to start a session.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
type="editable-card"
|
||||
hideAdd
|
||||
activeKey={activeSessionId ?? undefined}
|
||||
onChange={setActiveSession}
|
||||
onEdit={(key, action) => {
|
||||
if (action === 'remove') closeSession(key as string);
|
||||
}}
|
||||
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
||||
tabBarStyle={{ margin: 0, flexShrink: 0 }}
|
||||
items={sessions.map((session) => ({
|
||||
key: session.id,
|
||||
label: tabLabel(session),
|
||||
closable: true,
|
||||
style: { height: '100%', padding: 0 },
|
||||
children: (
|
||||
<div style={{ height: '100%', overflow: 'hidden' }}>
|
||||
{session.connection.protocol === 'ssh' ? (
|
||||
<SshTab session={session} />
|
||||
) : (
|
||||
<RdpTab session={session} />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
114
frontend/src/components/Tabs/SshTab.tsx
Normal file
114
frontend/src/components/Tabs/SshTab.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { useStore } from '../../store';
|
||||
import { Session } from '../../types';
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
}
|
||||
|
||||
function getWsUrl(connectionId: string, token: string): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// In dev, Vite proxies /ws → backend. In prod, nginx proxies /ws → backend.
|
||||
const host = window.location.host;
|
||||
return `${proto}//${host}/ws/ssh/${connectionId}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export const SshTab: React.FC<Props> = ({ session }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const token = useStore((s) => s.token) ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// --- Terminal setup ---
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: 'Menlo, Consolas, "Courier New", monospace',
|
||||
fontSize: 14,
|
||||
theme: {
|
||||
background: '#1a1a2e',
|
||||
foreground: '#e0e0e0',
|
||||
cursor: '#e0e0e0',
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(container);
|
||||
fitAddon.fit();
|
||||
|
||||
// --- WebSocket ---
|
||||
const ws = new WebSocket(getWsUrl(session.connection.id, token));
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
terminal.writeln('\x1b[32mConnecting…\x1b[0m');
|
||||
// Send initial size
|
||||
ws.send(
|
||||
JSON.stringify({ type: 'resize', cols: terminal.cols, rows: terminal.rows })
|
||||
);
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
terminal.write(new Uint8Array(event.data));
|
||||
} else {
|
||||
terminal.write(event.data as string);
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', (e) => {
|
||||
terminal.writeln(`\r\n\x1b[33mConnection closed (${e.code}).\x1b[0m`);
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
terminal.writeln('\r\n\x1b[31mWebSocket error.\x1b[0m');
|
||||
});
|
||||
|
||||
// Terminal input → WS (binary frame)
|
||||
terminal.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(new TextEncoder().encode(data));
|
||||
}
|
||||
});
|
||||
|
||||
// Resize → WS (JSON text frame)
|
||||
terminal.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
||||
}
|
||||
});
|
||||
|
||||
// Observe container size changes
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
try {
|
||||
fitAddon.fit();
|
||||
} catch {
|
||||
// ignore layout errors during unmount
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
ws.close();
|
||||
terminal.dispose();
|
||||
};
|
||||
}, [session.connection.id, token]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: '#1a1a2e',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
74
frontend/src/pages/LoginPage.tsx
Normal file
74
frontend/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Input, Button, Card, Typography, Alert } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { apiLogin } from '../api/client';
|
||||
import { useStore } from '../store';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export const LoginPage: React.FC = () => {
|
||||
const setAuth = useStore((s) => s.setAuth);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiLogin(values.username, values.password);
|
||||
setAuth(res.data.token, res.data.user);
|
||||
} catch {
|
||||
setError('Invalid username or password.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f0f2f5',
|
||||
}}
|
||||
>
|
||||
<Card style={{ width: 360, boxShadow: '0 4px 24px rgba(0,0,0,0.10)' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
mRemotify
|
||||
</Title>
|
||||
<Typography.Text type="secondary">Remote Connection Manager</Typography.Text>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
message={error}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
closable
|
||||
onClose={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form name="login" onFinish={onFinish} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="username" rules={[{ required: true, message: 'Username is required' }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="Username" size="large" autoFocus />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="password" rules={[{ required: true, message: 'Password is required' }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="Password" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block size="large">
|
||||
Sign in
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
85
frontend/src/store/index.ts
Normal file
85
frontend/src/store/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { create } from 'zustand';
|
||||
import { Connection, Folder, Session, User } from '../types';
|
||||
|
||||
interface AppState {
|
||||
// Auth
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
setAuth: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
|
||||
// Tree data
|
||||
folders: Folder[];
|
||||
connections: Connection[];
|
||||
setFolders: (folders: Folder[]) => void;
|
||||
setConnections: (connections: Connection[]) => void;
|
||||
|
||||
// Open sessions (tabs)
|
||||
sessions: Session[];
|
||||
activeSessionId: string | null;
|
||||
openSession: (connection: Connection) => void;
|
||||
closeSession: (sessionId: string) => void;
|
||||
setActiveSession: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set, get) => ({
|
||||
// Auth
|
||||
token: localStorage.getItem('token'),
|
||||
user: (() => {
|
||||
try {
|
||||
const u = localStorage.getItem('user');
|
||||
return u ? (JSON.parse(u) as User) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
|
||||
setAuth: (token, user) => {
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
set({ token, user });
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
set({ token: null, user: null, sessions: [], activeSessionId: null });
|
||||
},
|
||||
|
||||
// Tree
|
||||
folders: [],
|
||||
connections: [],
|
||||
setFolders: (folders) => set({ folders }),
|
||||
setConnections: (connections) => set({ connections }),
|
||||
|
||||
// Sessions
|
||||
sessions: [],
|
||||
activeSessionId: null,
|
||||
|
||||
openSession: (connection) => {
|
||||
const existing = get().sessions.find((s) => s.connection.id === connection.id);
|
||||
if (existing) {
|
||||
set({ activeSessionId: existing.id });
|
||||
return;
|
||||
}
|
||||
const id = `session-${Date.now()}-${Math.random()}`;
|
||||
const session: Session = { id, connection };
|
||||
set((state) => ({
|
||||
sessions: [...state.sessions, session],
|
||||
activeSessionId: id,
|
||||
}));
|
||||
},
|
||||
|
||||
closeSession: (sessionId) => {
|
||||
set((state) => {
|
||||
const sessions = state.sessions.filter((s) => s.id !== sessionId);
|
||||
const activeSessionId =
|
||||
state.activeSessionId === sessionId
|
||||
? (sessions[sessions.length - 1]?.id ?? null)
|
||||
: state.activeSessionId;
|
||||
return { sessions, activeSessionId };
|
||||
});
|
||||
},
|
||||
|
||||
setActiveSession: (sessionId) => set({ activeSessionId: sessionId }),
|
||||
}));
|
||||
84
frontend/src/types/guacamole.d.ts
vendored
Normal file
84
frontend/src/types/guacamole.d.ts
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
// Custom type declarations for guacamole-common-js.
|
||||
// We declare this instead of using @types/guacamole-common-js to avoid version mismatches.
|
||||
|
||||
declare namespace Guacamole {
|
||||
interface Tunnel {
|
||||
onerror: ((status: Status) => void) | null;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
}
|
||||
|
||||
class WebSocketTunnel implements Tunnel {
|
||||
constructor(
|
||||
tunnelURL: string,
|
||||
crossDomain?: boolean,
|
||||
extraTunnelHeaders?: Record<string, string>
|
||||
);
|
||||
onerror: ((status: Status) => void) | null;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
}
|
||||
|
||||
class Client {
|
||||
constructor(tunnel: Tunnel);
|
||||
connect(data?: string): void;
|
||||
disconnect(): void;
|
||||
getDisplay(): Display;
|
||||
sendKeyEvent(pressed: number, keysym: number): void;
|
||||
sendMouseState(mouseState: Mouse.State, applyDisplayScale?: boolean): void;
|
||||
onerror: ((status: Status) => void) | null;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
}
|
||||
|
||||
class Display {
|
||||
getElement(): HTMLDivElement;
|
||||
getWidth(): number;
|
||||
getHeight(): number;
|
||||
scale(scale: number): void;
|
||||
onresize: (() => void) | null;
|
||||
}
|
||||
|
||||
class Mouse {
|
||||
constructor(element: Element);
|
||||
onmousedown: ((mouseState: Mouse.State) => void) | null;
|
||||
onmouseup: ((mouseState: Mouse.State) => void) | null;
|
||||
onmousemove: ((mouseState: Mouse.State) => void) | null;
|
||||
onmouseout: ((mouseState: Mouse.State) => void) | null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Mouse {
|
||||
class State {
|
||||
x: number;
|
||||
y: number;
|
||||
left: boolean;
|
||||
middle: boolean;
|
||||
right: boolean;
|
||||
up: boolean;
|
||||
down: boolean;
|
||||
constructor(
|
||||
x: number,
|
||||
y: number,
|
||||
left: boolean,
|
||||
middle: boolean,
|
||||
right: boolean,
|
||||
up: boolean,
|
||||
down: boolean
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Keyboard {
|
||||
constructor(element: Element | Document);
|
||||
onkeydown: ((keysym: number) => void) | null;
|
||||
onkeyup: ((keysym: number) => void) | null;
|
||||
}
|
||||
|
||||
class Status {
|
||||
code: number;
|
||||
message: string;
|
||||
constructor(code: number, message?: string);
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'guacamole-common-js' {
|
||||
export = Guacamole;
|
||||
}
|
||||
47
frontend/src/types/index.ts
Normal file
47
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
protocol: 'ssh' | 'rdp';
|
||||
username: string;
|
||||
domain?: string | null;
|
||||
osType?: string | null;
|
||||
notes?: string | null;
|
||||
folderId?: string | null;
|
||||
privateKey?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ConnectionFormValues {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
protocol: 'ssh' | 'rdp';
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
domain?: string;
|
||||
osType?: string;
|
||||
notes?: string;
|
||||
folderId?: string | null;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
/** Unique ID for this open tab */
|
||||
id: string;
|
||||
connection: Connection;
|
||||
}
|
||||
Reference in New Issue
Block a user