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:
19
.env.example
Normal file
19
.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
# PostgreSQL connection URL
|
||||
POSTGRES_URL=postgresql://mremotify:mremotify@localhost:5432/mremotify
|
||||
|
||||
# AES-256 encryption key for passwords (must be exactly 32 characters)
|
||||
ENCRYPTION_KEY=change-me-to-a-random-32char-key!
|
||||
|
||||
# JWT signing secret
|
||||
JWT_SECRET=change-me-to-a-secure-jwt-secret
|
||||
|
||||
# Default admin credentials (used during first-time seeding)
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASSWORD=admin123
|
||||
|
||||
# Apache Guacamole daemon
|
||||
GUACD_HOST=guacd
|
||||
GUACD_PORT=4822
|
||||
|
||||
# Backend port
|
||||
PORT=3000
|
||||
5
.eslintignore
Normal file
5
.eslintignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.pnpm-store
|
||||
frontend/dist
|
||||
backend/dist
|
||||
24
.eslintrc.js
Normal file
24
.eslintrc.js
Normal file
@@ -0,0 +1,24 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'prettier',
|
||||
],
|
||||
settings: {
|
||||
react: { version: 'detect' },
|
||||
},
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
es2021: true,
|
||||
},
|
||||
};
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
91
README.md
91
README.md
@@ -1 +1,92 @@
|
||||
# mRemotify
|
||||
|
||||
A browser-based remote connection manager — open-source alternative to mRemoteNG.
|
||||
|
||||
Manage SSH and RDP connections through a clean web UI with a familiar tree-based layout and tabbed session view.
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Tech |
|
||||
|-----------|-------------------------------------------------|
|
||||
| Frontend | React 18 · TypeScript · Vite · Ant Design 5 |
|
||||
| Backend | Fastify · TypeScript · Prisma · PostgreSQL |
|
||||
| SSH | ssh2 (Node.js) proxied over WebSocket |
|
||||
| RDP | Apache Guacamole (guacd) + guacamole-common-js |
|
||||
| Auth | JWT · bcryptjs · AES-256 password encryption |
|
||||
| Infra | Docker Compose · pnpm workspaces |
|
||||
|
||||
## Features
|
||||
|
||||
- **Connection tree** — folders with drag-and-drop, Linux / Windows icons per connection
|
||||
- **Tabbed sessions** — each opened connection gets its own tab
|
||||
- **SSH sessions** — full xterm.js terminal over WebSocket
|
||||
- **RDP sessions** — Guacamole-based remote desktop in the browser
|
||||
- **Encrypted storage** — passwords stored AES-256 encrypted at rest
|
||||
- **JWT auth** — login-protected, all API + WebSocket routes secured
|
||||
|
||||
## Quick start (Docker Compose)
|
||||
|
||||
```bash
|
||||
# 1. Clone & enter
|
||||
git clone https://github.com/yourname/mremotify.git
|
||||
cd mremotify
|
||||
|
||||
# 2. Configure environment
|
||||
cp .env.example .env
|
||||
# Edit .env: set ENCRYPTION_KEY (32 chars), JWT_SECRET, ADMIN_PASSWORD
|
||||
|
||||
# 3. Start everything
|
||||
docker compose up -d
|
||||
|
||||
# 4. Open in browser
|
||||
open http://localhost
|
||||
```
|
||||
|
||||
Default login: `admin` / `admin123` (change via `ADMIN_USER` / `ADMIN_PASSWORD` in `.env` before first start).
|
||||
|
||||
## Development (without Docker)
|
||||
|
||||
Prerequisites: Node.js >= 20, pnpm >= 9, PostgreSQL running locally.
|
||||
|
||||
```bash
|
||||
# Install all workspace dependencies
|
||||
pnpm install
|
||||
|
||||
# Copy and fill in env
|
||||
cp .env.example .env
|
||||
# Set POSTGRES_URL to your local DB, etc.
|
||||
|
||||
# Run DB migrations + seed
|
||||
cd backend && npx prisma migrate dev && npx prisma db seed && cd ..
|
||||
|
||||
# Start backend + frontend in parallel
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Frontend dev server: http://localhost:5173
|
||||
Backend API: http://localhost:3000
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
mremotify/
|
||||
frontend/ React + Vite app
|
||||
backend/ Fastify API + WebSocket proxies
|
||||
docker/ Dockerfiles + nginx config
|
||||
docker-compose.yml
|
||||
.env.example
|
||||
pnpm-workspace.yaml
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|-------------------|-----------------------------------------------|
|
||||
| POSTGRES_URL | PostgreSQL connection string |
|
||||
| ENCRYPTION_KEY | 32-character key for AES-256 password crypto |
|
||||
| JWT_SECRET | Secret for signing JWT tokens |
|
||||
| ADMIN_USER | Initial admin username |
|
||||
| ADMIN_PASSWORD | Initial admin password |
|
||||
| GUACD_HOST | Hostname of guacd service |
|
||||
| GUACD_PORT | Port of guacd service (default: 4822) |
|
||||
| PORT | Backend port (default: 3000) |
|
||||
|
||||
35
backend/package.json
Normal file
35
backend/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:seed": "prisma db seed"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx src/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/jwt": "^8.0.1",
|
||||
"@fastify/websocket": "^8.3.1",
|
||||
"@prisma/client": "^5.17.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"fastify": "^4.28.1",
|
||||
"fastify-plugin": "^4.5.1",
|
||||
"ssh2": "^1.16.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20.16.1",
|
||||
"@types/ssh2": "^1.15.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"prisma": "^5.17.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
54
backend/prisma/schema.prisma
Normal file
54
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,54 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("POSTGRES_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
passwordHash String
|
||||
createdAt DateTime @default(now())
|
||||
folders Folder[]
|
||||
connections Connection[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Folder {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
parentId String?
|
||||
parent Folder? @relation("FolderChildren", fields: [parentId], references: [id])
|
||||
children Folder[] @relation("FolderChildren")
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
connections Connection[]
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("folders")
|
||||
}
|
||||
|
||||
model Connection {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
host String
|
||||
port Int
|
||||
protocol String
|
||||
username String
|
||||
encryptedPassword String?
|
||||
privateKey String?
|
||||
domain String?
|
||||
osType String?
|
||||
notes String?
|
||||
folderId String?
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("connections")
|
||||
}
|
||||
3
backend/prisma/seed.ts
Normal file
3
backend/prisma/seed.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Seed logic has been moved to src/seed.ts.
|
||||
// The package.json prisma.seed config points to: tsx src/seed.ts
|
||||
// Run with: pnpm db:seed
|
||||
61
backend/src/index.ts
Normal file
61
backend/src/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import websocketPlugin from '@fastify/websocket';
|
||||
import prismaPlugin from './plugins/prisma';
|
||||
import { authRoutes } from './routes/auth';
|
||||
import { folderRoutes } from './routes/folders';
|
||||
import { connectionRoutes } from './routes/connections';
|
||||
import { sshWebsocket } from './websocket/ssh';
|
||||
import { rdpWebsocket } from './websocket/rdp';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
authenticate: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
async function buildApp(): Promise<FastifyInstance> {
|
||||
const fastify = Fastify({ logger: true });
|
||||
|
||||
await fastify.register(cors, { origin: true, credentials: true });
|
||||
|
||||
await fastify.register(jwt, {
|
||||
secret: process.env.JWT_SECRET || 'supersecret-change-me',
|
||||
});
|
||||
|
||||
await fastify.register(websocketPlugin);
|
||||
|
||||
await fastify.register(prismaPlugin);
|
||||
|
||||
// Reusable auth preHandler
|
||||
fastify.decorate('authenticate', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
await req.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.send(err);
|
||||
}
|
||||
});
|
||||
|
||||
// REST routes
|
||||
await fastify.register(authRoutes, { prefix: '/api/auth' });
|
||||
await fastify.register(folderRoutes, { prefix: '/api/folders' });
|
||||
await fastify.register(connectionRoutes, { prefix: '/api/connections' });
|
||||
|
||||
// WebSocket routes
|
||||
await fastify.register(sshWebsocket);
|
||||
await fastify.register(rdpWebsocket);
|
||||
|
||||
return fastify;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const app = await buildApp();
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
await app.listen({ port, host: '0.0.0.0' });
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
25
backend/src/plugins/prisma.ts
Normal file
25
backend/src/plugins/prisma.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
prisma: PrismaClient;
|
||||
}
|
||||
}
|
||||
|
||||
async function prismaPlugin(fastify: FastifyInstance) {
|
||||
const prisma = new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
|
||||
});
|
||||
|
||||
await prisma.$connect();
|
||||
|
||||
fastify.decorate('prisma', prisma);
|
||||
|
||||
fastify.addHook('onClose', async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
export default fp(prismaPlugin, { name: 'prisma' });
|
||||
58
backend/src/routes/auth.ts
Normal file
58
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
interface LoginBody {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export async function authRoutes(fastify: FastifyInstance) {
|
||||
// POST /api/auth/login
|
||||
fastify.post<{ Body: LoginBody }>(
|
||||
'/login',
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['username', 'password'],
|
||||
properties: {
|
||||
username: { type: 'string' },
|
||||
password: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request: FastifyRequest<{ Body: LoginBody }>, reply: FastifyReply) => {
|
||||
const { username, password } = request.body;
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({ where: { username } });
|
||||
if (!user) {
|
||||
return reply.status(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return reply.status(401).send({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const token = fastify.jwt.sign(
|
||||
{ id: user.id, username: user.username },
|
||||
{ expiresIn: '12h' }
|
||||
);
|
||||
|
||||
return reply.send({ token, user: { id: user.id, username: user.username } });
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/auth/me — verify token and return current user
|
||||
fastify.get(
|
||||
'/me',
|
||||
{ preHandler: [fastify.authenticate] },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const payload = request.user as { id: string; username: string };
|
||||
const user = await fastify.prisma.user.findUnique({ where: { id: payload.id } });
|
||||
if (!user) return reply.status(404).send({ error: 'User not found' });
|
||||
return reply.send({ id: user.id, username: user.username });
|
||||
}
|
||||
);
|
||||
}
|
||||
155
backend/src/routes/connections.ts
Normal file
155
backend/src/routes/connections.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { encrypt } from '../utils/encryption';
|
||||
|
||||
interface JwtPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface ConnectionBody {
|
||||
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 async function connectionRoutes(fastify: FastifyInstance) {
|
||||
fastify.addHook('preHandler', fastify.authenticate);
|
||||
|
||||
// GET /api/connections
|
||||
fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const connections = await fastify.prisma.connection.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
host: true,
|
||||
port: true,
|
||||
protocol: true,
|
||||
username: true,
|
||||
domain: true,
|
||||
osType: true,
|
||||
notes: true,
|
||||
folderId: true,
|
||||
createdAt: true,
|
||||
// Do NOT expose encryptedPassword or privateKey in list
|
||||
},
|
||||
});
|
||||
return reply.send(connections);
|
||||
});
|
||||
|
||||
// GET /api/connections/:id
|
||||
fastify.get<{ Params: { id: string } }>('/:id', async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const conn = await fastify.prisma.connection.findFirst({
|
||||
where: { id: request.params.id, userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
host: true,
|
||||
port: true,
|
||||
protocol: true,
|
||||
username: true,
|
||||
domain: true,
|
||||
osType: true,
|
||||
notes: true,
|
||||
folderId: true,
|
||||
privateKey: true,
|
||||
createdAt: true,
|
||||
// still omit encryptedPassword
|
||||
},
|
||||
});
|
||||
if (!conn) return reply.status(404).send({ error: 'Connection not found' });
|
||||
return reply.send(conn);
|
||||
});
|
||||
|
||||
// POST /api/connections
|
||||
fastify.post<{ Body: ConnectionBody }>('/', async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { password, ...rest } = request.body;
|
||||
|
||||
const connection = await fastify.prisma.connection.create({
|
||||
data: {
|
||||
...rest,
|
||||
folderId: rest.folderId || null,
|
||||
encryptedPassword: password ? encrypt(password) : null,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
host: true,
|
||||
port: true,
|
||||
protocol: true,
|
||||
username: true,
|
||||
domain: true,
|
||||
osType: true,
|
||||
notes: true,
|
||||
folderId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return reply.status(201).send(connection);
|
||||
});
|
||||
|
||||
// PATCH /api/connections/:id
|
||||
fastify.patch<{ Params: { id: string }; Body: Partial<ConnectionBody> }>(
|
||||
'/:id',
|
||||
async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { id } = request.params;
|
||||
const { password, folderId, ...rest } = request.body;
|
||||
|
||||
const existing = await fastify.prisma.connection.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
if (!existing) return reply.status(404).send({ error: 'Connection not found' });
|
||||
|
||||
const updated = await fastify.prisma.connection.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(folderId !== undefined && { folderId: folderId ?? null }),
|
||||
...(password !== undefined && { encryptedPassword: password ? encrypt(password) : null }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
host: true,
|
||||
port: true,
|
||||
protocol: true,
|
||||
username: true,
|
||||
domain: true,
|
||||
osType: true,
|
||||
notes: true,
|
||||
folderId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/connections/:id
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { id } = request.params;
|
||||
|
||||
const existing = await fastify.prisma.connection.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
if (!existing) return reply.status(404).send({ error: 'Connection not found' });
|
||||
|
||||
await fastify.prisma.connection.delete({ where: { id } });
|
||||
return reply.status(204).send();
|
||||
});
|
||||
}
|
||||
93
backend/src/routes/folders.ts
Normal file
93
backend/src/routes/folders.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
interface JwtPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface CreateFolderBody {
|
||||
name: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
interface UpdateFolderBody {
|
||||
name?: string;
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
export async function folderRoutes(fastify: FastifyInstance) {
|
||||
// All folder routes require authentication
|
||||
fastify.addHook('preHandler', fastify.authenticate);
|
||||
|
||||
// GET /api/folders — list all folders for the current user
|
||||
fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const folders = await fastify.prisma.folder.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return reply.send(folders);
|
||||
});
|
||||
|
||||
// POST /api/folders — create folder
|
||||
fastify.post<{ Body: CreateFolderBody }>(
|
||||
'/',
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
parentId: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request: FastifyRequest<{ Body: CreateFolderBody }>, reply: FastifyReply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { name, parentId } = request.body;
|
||||
|
||||
const folder = await fastify.prisma.folder.create({
|
||||
data: { name, parentId: parentId || null, userId: user.id },
|
||||
});
|
||||
return reply.status(201).send(folder);
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /api/folders/:id — update folder
|
||||
fastify.patch<{ Params: { id: string }; Body: UpdateFolderBody }>(
|
||||
'/:id',
|
||||
async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { id } = request.params;
|
||||
const { name, parentId } = request.body;
|
||||
|
||||
const folder = await fastify.prisma.folder.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
});
|
||||
if (!folder) return reply.status(404).send({ error: 'Folder not found' });
|
||||
|
||||
const updated = await fastify.prisma.folder.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(name !== undefined && { name }),
|
||||
...(parentId !== undefined && { parentId: parentId ?? null }),
|
||||
},
|
||||
});
|
||||
return reply.send(updated);
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /api/folders/:id — delete folder (connections become root-level)
|
||||
fastify.delete<{ Params: { id: string } }>('/:id', async (request, reply) => {
|
||||
const user = request.user as JwtPayload;
|
||||
const { id } = request.params;
|
||||
|
||||
const folder = await fastify.prisma.folder.findFirst({ where: { id, userId: user.id } });
|
||||
if (!folder) return reply.status(404).send({ error: 'Folder not found' });
|
||||
|
||||
await fastify.prisma.folder.delete({ where: { id } });
|
||||
return reply.status(204).send();
|
||||
});
|
||||
}
|
||||
29
backend/src/seed.ts
Normal file
29
backend/src/seed.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Database seed — run with `tsx src/seed.ts` locally or `node dist/seed.js` in Docker.
|
||||
* Creates the initial admin user if one does not already exist.
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const username = process.env.ADMIN_USER || 'admin';
|
||||
const password = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username } });
|
||||
if (!existing) {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
await prisma.user.create({ data: { username, passwordHash } });
|
||||
console.log(`[seed] Created admin user: ${username}`);
|
||||
} else {
|
||||
console.log(`[seed] Admin user '${username}' already exists — skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('[seed] error:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
26
backend/src/utils/encryption.ts
Normal file
26
backend/src/utils/encryption.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
const raw = process.env.ENCRYPTION_KEY || '';
|
||||
// Derive exactly 32 bytes from whatever key string is provided
|
||||
return crypto.createHash('sha256').update(raw).digest();
|
||||
}
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
return `${iv.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
export function decrypt(encryptedText: string): string {
|
||||
const [ivHex, encryptedHex] = encryptedText.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const encrypted = Buffer.from(encryptedHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
return decrypted.toString('utf8');
|
||||
}
|
||||
212
backend/src/websocket/rdp.ts
Normal file
212
backend/src/websocket/rdp.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import net from 'net';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import '@fastify/websocket'; // trigger type augmentation for { websocket: true } route option
|
||||
import { WebSocket } from 'ws';
|
||||
import { decrypt } from '../utils/encryption';
|
||||
|
||||
interface JwtPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guacamole protocol helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function encodeElement(value: string): string {
|
||||
return `${value.length}.${value}`;
|
||||
}
|
||||
|
||||
function buildInstruction(opcode: string, ...args: string[]): string {
|
||||
return [encodeElement(opcode), ...args.map(encodeElement)].join(',') + ';';
|
||||
}
|
||||
|
||||
/** Parse a single complete Guacamole instruction string into [opcode, ...args] */
|
||||
function parseInstruction(instruction: string): string[] {
|
||||
const raw = instruction.endsWith(';') ? instruction.slice(0, -1) : instruction;
|
||||
const elements: string[] = [];
|
||||
let pos = 0;
|
||||
|
||||
while (pos < raw.length) {
|
||||
const dotPos = raw.indexOf('.', pos);
|
||||
if (dotPos === -1) break;
|
||||
const length = parseInt(raw.substring(pos, dotPos), 10);
|
||||
if (isNaN(length)) break;
|
||||
const value = raw.substring(dotPos + 1, dotPos + 1 + length);
|
||||
elements.push(value);
|
||||
pos = dotPos + 1 + length;
|
||||
if (raw[pos] === ',') pos++;
|
||||
}
|
||||
|
||||
return elements; // elements[0] = opcode, rest = args
|
||||
}
|
||||
|
||||
/** Read from a TCP socket until a complete Guacamole instruction (ending with ';') is received. */
|
||||
function readInstruction(
|
||||
tcpSocket: net.Socket,
|
||||
buf: { value: string }
|
||||
): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const check = () => {
|
||||
const idx = buf.value.indexOf(';');
|
||||
if (idx !== -1) {
|
||||
const instruction = buf.value.substring(0, idx + 1);
|
||||
buf.value = buf.value.substring(idx + 1);
|
||||
resolve(parseInstruction(instruction));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (check()) return;
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
buf.value += data.toString('utf8');
|
||||
if (check()) {
|
||||
tcpSocket.removeListener('data', onData);
|
||||
tcpSocket.removeListener('error', onError);
|
||||
}
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
tcpSocket.removeListener('data', onData);
|
||||
reject(err);
|
||||
};
|
||||
|
||||
tcpSocket.on('data', onData);
|
||||
tcpSocket.on('error', onError);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RDP WebSocket handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function rdpWebsocket(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
'/ws/rdp/:connectionId',
|
||||
{ websocket: true },
|
||||
async (socket: WebSocket, request) => {
|
||||
// --- Auth ---
|
||||
const query = request.query as { token?: string };
|
||||
let userId: string;
|
||||
|
||||
try {
|
||||
if (!query.token) throw new Error('No token');
|
||||
const payload = fastify.jwt.verify<JwtPayload>(query.token);
|
||||
userId = payload.id;
|
||||
} catch {
|
||||
socket.close(1008, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Fetch connection ---
|
||||
const { connectionId } = request.params as { connectionId: string };
|
||||
const conn = await fastify.prisma.connection.findFirst({
|
||||
where: { id: connectionId, userId },
|
||||
});
|
||||
|
||||
if (!conn) {
|
||||
socket.close(1008, 'Connection not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const guacdHost = process.env.GUACD_HOST || 'localhost';
|
||||
const guacdPort = Number(process.env.GUACD_PORT) || 4822;
|
||||
|
||||
// --- Connect to guacd ---
|
||||
const guacd = net.createConnection(guacdPort, guacdHost);
|
||||
const tcpBuf = { value: '' };
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
guacd.once('connect', resolve);
|
||||
guacd.once('error', reject);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'guacd connect failed';
|
||||
fastify.log.error({ err }, 'guacd connect failed');
|
||||
socket.close(1011, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Select protocol
|
||||
guacd.write(buildInstruction('select', conn.protocol));
|
||||
|
||||
// 2. Read args list from guacd
|
||||
const argsInstruction = await readInstruction(guacd, tcpBuf);
|
||||
// argsInstruction = ['args', 'hostname', 'port', 'username', ...]
|
||||
const argNames = argsInstruction.slice(1);
|
||||
|
||||
const decryptedPassword = conn.encryptedPassword ? decrypt(conn.encryptedPassword) : '';
|
||||
|
||||
// Build a map of known RDP parameters
|
||||
const rdpParams: Record<string, string> = {
|
||||
hostname: conn.host,
|
||||
port: String(conn.port),
|
||||
username: conn.username,
|
||||
password: decryptedPassword,
|
||||
domain: conn.domain || '',
|
||||
width: '1280',
|
||||
height: '720',
|
||||
dpi: '96',
|
||||
'color-depth': '32',
|
||||
'ignore-cert': 'true',
|
||||
security: 'any',
|
||||
'disable-auth': 'false',
|
||||
'enable-drive': 'false',
|
||||
'create-drive-path': 'false',
|
||||
'enable-printing': 'false',
|
||||
};
|
||||
|
||||
// 3. Connect with values for each arg guacd requested
|
||||
const argValues = argNames.map((name) => rdpParams[name] ?? '');
|
||||
guacd.write(buildInstruction('connect', ...argValues));
|
||||
|
||||
// 4. Read ready instruction
|
||||
const readyInstruction = await readInstruction(guacd, tcpBuf);
|
||||
if (readyInstruction[0] !== 'ready') {
|
||||
throw new Error(`guacd handshake failed: expected 'ready', got '${readyInstruction[0]}'`);
|
||||
}
|
||||
|
||||
// 5. Flush any remaining buffered bytes to socket
|
||||
if (tcpBuf.value.length > 0) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(tcpBuf.value);
|
||||
}
|
||||
tcpBuf.value = '';
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Guacamole handshake failed';
|
||||
fastify.log.error({ err }, 'Guacamole handshake failed');
|
||||
guacd.destroy();
|
||||
socket.close(1011, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Proxy mode: bidirectional forwarding ---
|
||||
guacd.on('data', (data: Buffer) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data.toString('utf8'));
|
||||
}
|
||||
});
|
||||
|
||||
guacd.on('end', () => socket.close());
|
||||
guacd.on('error', (err) => {
|
||||
fastify.log.error({ err }, 'guacd socket error');
|
||||
socket.close(1011, err.message);
|
||||
});
|
||||
|
||||
socket.on('message', (message: Buffer | string) => {
|
||||
if (guacd.writable) {
|
||||
guacd.write(typeof message === 'string' ? message : message.toString('utf8'));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
guacd.destroy();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
135
backend/src/websocket/ssh.ts
Normal file
135
backend/src/websocket/ssh.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import '@fastify/websocket'; // trigger type augmentation for { websocket: true } route option
|
||||
import { WebSocket } from 'ws';
|
||||
import { Client as SshClient, ConnectConfig } from 'ssh2';
|
||||
import { decrypt } from '../utils/encryption';
|
||||
|
||||
interface JwtPayload {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface ResizeMessage {
|
||||
type: 'resize';
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export async function sshWebsocket(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
'/ws/ssh/:connectionId',
|
||||
{ websocket: true },
|
||||
async (socket: WebSocket, request) => {
|
||||
// --- Auth via ?token= query param ---
|
||||
const query = request.query as { token?: string };
|
||||
let userId: string;
|
||||
|
||||
try {
|
||||
if (!query.token) throw new Error('No token');
|
||||
const payload = fastify.jwt.verify<JwtPayload>(query.token);
|
||||
userId = payload.id;
|
||||
} catch {
|
||||
socket.close(1008, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Fetch connection ---
|
||||
const { connectionId } = request.params as { connectionId: string };
|
||||
const conn = await fastify.prisma.connection.findFirst({
|
||||
where: { id: connectionId, userId },
|
||||
});
|
||||
|
||||
if (!conn) {
|
||||
socket.close(1008, 'Connection not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Build SSH config ---
|
||||
const sshConfig: ConnectConfig = {
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
username: conn.username,
|
||||
readyTimeout: 10_000,
|
||||
};
|
||||
|
||||
if (conn.privateKey) {
|
||||
sshConfig.privateKey = conn.privateKey;
|
||||
} else if (conn.encryptedPassword) {
|
||||
sshConfig.password = decrypt(conn.encryptedPassword);
|
||||
}
|
||||
|
||||
// --- Open SSH session ---
|
||||
const ssh = new SshClient();
|
||||
|
||||
ssh.on('ready', () => {
|
||||
ssh.shell({ term: 'xterm-256color', cols: 80, rows: 24 }, (err, stream) => {
|
||||
if (err) {
|
||||
socket.send(JSON.stringify({ type: 'error', message: err.message }));
|
||||
socket.close();
|
||||
ssh.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// SSH stdout/stderr → WebSocket (binary frames)
|
||||
stream.on('data', (data: Buffer) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (data: Buffer) => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(data);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('close', () => {
|
||||
socket.close();
|
||||
ssh.end();
|
||||
});
|
||||
|
||||
// WebSocket → SSH stdin
|
||||
socket.on('message', (message: Buffer | string, isBinary: boolean) => {
|
||||
if (!stream.writable) return;
|
||||
|
||||
if (isBinary) {
|
||||
// Raw terminal input
|
||||
stream.write(Buffer.isBuffer(message) ? message : Buffer.from(message as string));
|
||||
} else {
|
||||
// Control message (JSON text frame)
|
||||
try {
|
||||
const msg: ResizeMessage = JSON.parse(message.toString());
|
||||
if (msg.type === 'resize') {
|
||||
stream.setWindow(msg.rows || 24, msg.cols || 80, 0, 0);
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON text: treat as raw input
|
||||
stream.write(message.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
stream.close();
|
||||
ssh.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ssh.on('error', (err) => {
|
||||
fastify.log.error({ err }, 'SSH error');
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(`\r\n\x1b[31mSSH error: ${err.message}\x1b[0m\r\n`);
|
||||
socket.close(1011, err.message);
|
||||
}
|
||||
});
|
||||
|
||||
ssh.connect(sshConfig);
|
||||
|
||||
// If WebSocket closes before SSH is ready
|
||||
socket.on('close', () => {
|
||||
ssh.end();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
15
backend/tsconfig.json
Normal file
15
backend/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2021"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
61
docker-compose.yml
Normal file
61
docker-compose.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: mremotify
|
||||
POSTGRES_PASSWORD: mremotify
|
||||
POSTGRES_DB: mremotify
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U mremotify']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
guacd:
|
||||
image: guacamole/guacd:1.5.4
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
POSTGRES_URL: postgresql://mremotify:mremotify@postgres:5432/mremotify
|
||||
GUACD_HOST: guacd
|
||||
GUACD_PORT: '4822'
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
guacd:
|
||||
condition: service_started
|
||||
networks:
|
||||
- internal
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/frontend.Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '80:80'
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- internal
|
||||
|
||||
networks:
|
||||
internal:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
38
docker/backend.Dockerfile
Normal file
38
docker/backend.Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- Build stage ----
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install all dependencies (including devDeps for tsc, prisma CLI, tsx)
|
||||
COPY backend/package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy backend source files
|
||||
COPY backend/prisma ./prisma
|
||||
COPY backend/src ./src
|
||||
COPY backend/tsconfig.json ./
|
||||
|
||||
# Generate Prisma client and compile TypeScript
|
||||
RUN npx prisma generate
|
||||
RUN npx tsc
|
||||
|
||||
# ---- Runtime stage ----
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only production runtime artifacts
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/package.json ./
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Migrate schema, seed admin user, then start the server
|
||||
CMD ["sh", "-c", \
|
||||
"node_modules/.bin/prisma migrate deploy && \
|
||||
node dist/seed.js && \
|
||||
node dist/index.js"]
|
||||
24
docker/frontend.Dockerfile
Normal file
24
docker/frontend.Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- Build stage ----
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY frontend/ .
|
||||
|
||||
# Type-check and build
|
||||
RUN npm run build
|
||||
|
||||
# ---- Serve stage ----
|
||||
FROM nginx:1.27-alpine AS runner
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
44
docker/nginx.conf
Normal file
44
docker/nginx.conf
Normal file
@@ -0,0 +1,44 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Serve static frontend assets
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# All unknown routes → index.html (React SPA)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy REST API to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy WebSocket connections to backend
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin" always;
|
||||
|
||||
# Compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json;
|
||||
gzip_min_length 1024;
|
||||
}
|
||||
28
frontend/index.html
Normal file
28
frontend/index.html
Normal 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
30
frontend/package.json
Normal 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
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;
|
||||
}
|
||||
16
frontend/tsconfig.json
Normal file
16
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal 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
20
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "mremotify",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --parallel -r dev",
|
||||
"build": "pnpm -r build",
|
||||
"lint": "eslint . --ext .ts,.tsx --ignore-path .eslintignore",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,json,md}\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react": "^7.35.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- 'frontend'
|
||||
- 'backend'
|
||||
9
tsconfig.base.json
Normal file
9
tsconfig.base.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user