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
|
||||||
|
}
|
||||||
93
README.md
93
README.md
@@ -1 +1,92 @@
|
|||||||
# mRemotify
|
# 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