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:
FelixG
2026-02-22 12:21:36 +01:00
parent 8bc43896fb
commit 3802924c6a
44 changed files with 2707 additions and 1 deletions

28
frontend/index.html Normal file
View File

@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>mRemotify</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.4.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"antd": "^5.20.5",
"axios": "^1.7.7",
"guacamole-common-js": "^1.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.4",
"vite": "^5.4.3"
}
}

25
frontend/src/App.tsx Normal file
View 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;

View 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;

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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',
}}
/>
);
};

View 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>
),
}))}
/>
);
};

View 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
View 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>
);

View 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>
);
};

View 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
View 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;
}

View 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;
}

16
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"moduleResolution": "bundler",
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"useDefineForClassFields": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:3000',
ws: true,
changeOrigin: true,
},
},
},
});