connection profiles and ssh key encryption
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "profiles" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"username" TEXT,
|
||||||
|
"encryptedPassword" TEXT,
|
||||||
|
"privateKey" TEXT,
|
||||||
|
"domain" TEXT,
|
||||||
|
"clipboardEnabled" BOOLEAN,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "profiles_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey (profiles → users)
|
||||||
|
ALTER TABLE "profiles" ADD CONSTRAINT "profiles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AlterTable (connections: add profileId)
|
||||||
|
ALTER TABLE "connections" ADD COLUMN "profileId" TEXT;
|
||||||
|
|
||||||
|
-- AddForeignKey (connections → profiles)
|
||||||
|
ALTER TABLE "connections" ADD CONSTRAINT "connections_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable: add protocol column (default 'ssh' for any existing rows)
|
||||||
|
ALTER TABLE "profiles" ADD COLUMN "protocol" TEXT NOT NULL DEFAULT 'ssh';
|
||||||
@@ -15,6 +15,7 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
folders Folder[]
|
folders Folder[]
|
||||||
connections Connection[]
|
connections Connection[]
|
||||||
|
profiles Profile[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -48,9 +49,28 @@ model Connection {
|
|||||||
clipboardEnabled Boolean @default(true)
|
clipboardEnabled Boolean @default(true)
|
||||||
folderId String?
|
folderId String?
|
||||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||||
|
profileId String?
|
||||||
|
profile Profile? @relation(fields: [profileId], references: [id], onDelete: SetNull)
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@map("connections")
|
@@map("connections")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Profile {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
protocol String
|
||||||
|
username String?
|
||||||
|
encryptedPassword String?
|
||||||
|
privateKey String?
|
||||||
|
domain String?
|
||||||
|
clipboardEnabled Boolean?
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
connections Connection[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@map("profiles")
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import prismaPlugin from './plugins/prisma';
|
|||||||
import { authRoutes } from './routes/auth';
|
import { authRoutes } from './routes/auth';
|
||||||
import { folderRoutes } from './routes/folders';
|
import { folderRoutes } from './routes/folders';
|
||||||
import { connectionRoutes } from './routes/connections';
|
import { connectionRoutes } from './routes/connections';
|
||||||
|
import { profileRoutes } from './routes/profiles';
|
||||||
import { sshWebsocket } from './websocket/ssh';
|
import { sshWebsocket } from './websocket/ssh';
|
||||||
import { rdpWebsocket } from './websocket/rdp';
|
import { rdpWebsocket } from './websocket/rdp';
|
||||||
|
import { encrypt } from './utils/encryption';
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -41,6 +43,7 @@ async function buildApp(): Promise<FastifyInstance> {
|
|||||||
await fastify.register(authRoutes, { prefix: '/api/auth' });
|
await fastify.register(authRoutes, { prefix: '/api/auth' });
|
||||||
await fastify.register(folderRoutes, { prefix: '/api/folders' });
|
await fastify.register(folderRoutes, { prefix: '/api/folders' });
|
||||||
await fastify.register(connectionRoutes, { prefix: '/api/connections' });
|
await fastify.register(connectionRoutes, { prefix: '/api/connections' });
|
||||||
|
await fastify.register(profileRoutes, { prefix: '/api/profiles' });
|
||||||
|
|
||||||
// WebSocket routes
|
// WebSocket routes
|
||||||
await fastify.register(sshWebsocket);
|
await fastify.register(sshWebsocket);
|
||||||
@@ -49,8 +52,29 @@ async function buildApp(): Promise<FastifyInstance> {
|
|||||||
return fastify;
|
return fastify;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One-time migration: encrypt any plaintext private keys left in the DB.
|
||||||
|
* Encrypted values contain a ":" (iv:ciphertext), plaintext keys don't. */
|
||||||
|
async function migratePrivateKeys(app: FastifyInstance) {
|
||||||
|
for (const table of ['connection', 'profile'] as const) {
|
||||||
|
const rows = await (app.prisma[table] as any).findMany({
|
||||||
|
where: { privateKey: { not: null } },
|
||||||
|
select: { id: true, privateKey: true },
|
||||||
|
});
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.privateKey && !row.privateKey.includes(':')) {
|
||||||
|
await (app.prisma[table] as any).update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: { privateKey: encrypt(row.privateKey) },
|
||||||
|
});
|
||||||
|
app.log.info(`[migrate] Encrypted plaintext privateKey in ${table} ${row.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const app = await buildApp();
|
const app = await buildApp();
|
||||||
|
await migratePrivateKeys(app);
|
||||||
const port = Number(process.env.PORT) || 3000;
|
const port = Number(process.env.PORT) || 3000;
|
||||||
await app.listen({ port, host: '0.0.0.0' });
|
await app.listen({ port, host: '0.0.0.0' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { encrypt } from '../utils/encryption';
|
import { encrypt, decrypt } from '../utils/encryption';
|
||||||
|
|
||||||
interface JwtPayload {
|
interface JwtPayload {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +19,7 @@ interface ConnectionBody {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
clipboardEnabled?: boolean;
|
clipboardEnabled?: boolean;
|
||||||
folderId?: string | null;
|
folderId?: string | null;
|
||||||
|
profileId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectionRoutes(fastify: FastifyInstance) {
|
export async function connectionRoutes(fastify: FastifyInstance) {
|
||||||
@@ -42,6 +43,7 @@ export async function connectionRoutes(fastify: FastifyInstance) {
|
|||||||
notes: true,
|
notes: true,
|
||||||
clipboardEnabled: true,
|
clipboardEnabled: true,
|
||||||
folderId: true,
|
folderId: true,
|
||||||
|
profileId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
// Do NOT expose encryptedPassword or privateKey in list
|
// Do NOT expose encryptedPassword or privateKey in list
|
||||||
},
|
},
|
||||||
@@ -66,25 +68,31 @@ export async function connectionRoutes(fastify: FastifyInstance) {
|
|||||||
notes: true,
|
notes: true,
|
||||||
clipboardEnabled: true,
|
clipboardEnabled: true,
|
||||||
folderId: true,
|
folderId: true,
|
||||||
|
profileId: true,
|
||||||
privateKey: true,
|
privateKey: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
// still omit encryptedPassword
|
// still omit encryptedPassword
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!conn) return reply.status(404).send({ error: 'Connection not found' });
|
if (!conn) return reply.status(404).send({ error: 'Connection not found' });
|
||||||
return reply.send(conn);
|
return reply.send({
|
||||||
|
...conn,
|
||||||
|
privateKey: conn.privateKey ? decrypt(conn.privateKey) : null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/connections
|
// POST /api/connections
|
||||||
fastify.post<{ Body: ConnectionBody }>('/', async (request, reply) => {
|
fastify.post<{ Body: ConnectionBody }>('/', async (request, reply) => {
|
||||||
const user = request.user as JwtPayload;
|
const user = request.user as JwtPayload;
|
||||||
const { password, ...rest } = request.body;
|
const { password, privateKey, ...rest } = request.body;
|
||||||
|
|
||||||
const connection = await fastify.prisma.connection.create({
|
const connection = await fastify.prisma.connection.create({
|
||||||
data: {
|
data: {
|
||||||
...rest,
|
...rest,
|
||||||
folderId: rest.folderId || null,
|
folderId: rest.folderId || null,
|
||||||
|
profileId: rest.profileId || null,
|
||||||
encryptedPassword: password ? encrypt(password) : null,
|
encryptedPassword: password ? encrypt(password) : null,
|
||||||
|
privateKey: privateKey ? encrypt(privateKey) : null,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -99,6 +107,7 @@ export async function connectionRoutes(fastify: FastifyInstance) {
|
|||||||
notes: true,
|
notes: true,
|
||||||
clipboardEnabled: true,
|
clipboardEnabled: true,
|
||||||
folderId: true,
|
folderId: true,
|
||||||
|
profileId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -111,7 +120,7 @@ export async function connectionRoutes(fastify: FastifyInstance) {
|
|||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const user = request.user as JwtPayload;
|
const user = request.user as JwtPayload;
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const { password, folderId, ...rest } = request.body;
|
const { password, privateKey, folderId, profileId, ...rest } = request.body;
|
||||||
|
|
||||||
const existing = await fastify.prisma.connection.findFirst({
|
const existing = await fastify.prisma.connection.findFirst({
|
||||||
where: { id, userId: user.id },
|
where: { id, userId: user.id },
|
||||||
@@ -123,7 +132,9 @@ export async function connectionRoutes(fastify: FastifyInstance) {
|
|||||||
data: {
|
data: {
|
||||||
...rest,
|
...rest,
|
||||||
...(folderId !== undefined && { folderId: folderId ?? null }),
|
...(folderId !== undefined && { folderId: folderId ?? null }),
|
||||||
|
...(profileId !== undefined && { profileId: profileId ?? null }),
|
||||||
...(password !== undefined && { encryptedPassword: password ? encrypt(password) : null }),
|
...(password !== undefined && { encryptedPassword: password ? encrypt(password) : null }),
|
||||||
|
...(privateKey !== undefined && { privateKey: privateKey ? encrypt(privateKey) : null }),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -137,6 +148,7 @@ export async function connectionRoutes(fastify: FastifyInstance) {
|
|||||||
notes: true,
|
notes: true,
|
||||||
clipboardEnabled: true,
|
clipboardEnabled: true,
|
||||||
folderId: true,
|
folderId: true,
|
||||||
|
profileId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
151
backend/src/routes/profiles.ts
Normal file
151
backend/src/routes/profiles.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import { encrypt, decrypt } from '../utils/encryption';
|
||||||
|
|
||||||
|
interface JwtPayload {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileBody {
|
||||||
|
name: string;
|
||||||
|
protocol: 'ssh' | 'rdp';
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
domain?: string;
|
||||||
|
clipboardEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function profileRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.addHook('preHandler', fastify.authenticate);
|
||||||
|
|
||||||
|
// GET /api/profiles
|
||||||
|
fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const user = request.user as JwtPayload;
|
||||||
|
const profiles = await fastify.prisma.profile.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
protocol: true,
|
||||||
|
username: true,
|
||||||
|
domain: true,
|
||||||
|
clipboardEnabled: true,
|
||||||
|
privateKey: true,
|
||||||
|
encryptedPassword: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Return hasPassword/hasPrivateKey flags instead of raw values
|
||||||
|
const result = profiles.map(({ encryptedPassword, privateKey, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
hasPassword: !!encryptedPassword,
|
||||||
|
hasPrivateKey: !!privateKey,
|
||||||
|
}));
|
||||||
|
return reply.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/profiles/:id
|
||||||
|
fastify.get<{ Params: { id: string } }>('/:id', async (request, reply) => {
|
||||||
|
const user = request.user as JwtPayload;
|
||||||
|
const profile = await fastify.prisma.profile.findFirst({
|
||||||
|
where: { id: request.params.id, userId: user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
protocol: true,
|
||||||
|
username: true,
|
||||||
|
domain: true,
|
||||||
|
clipboardEnabled: true,
|
||||||
|
privateKey: true,
|
||||||
|
encryptedPassword: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!profile) return reply.status(404).send({ error: 'Profile not found' });
|
||||||
|
const { encryptedPassword, privateKey, ...rest } = profile;
|
||||||
|
return reply.send({
|
||||||
|
...rest,
|
||||||
|
privateKey: privateKey ? decrypt(privateKey) : null,
|
||||||
|
hasPassword: !!encryptedPassword,
|
||||||
|
hasPrivateKey: !!privateKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/profiles
|
||||||
|
fastify.post<{ Body: ProfileBody }>('/', async (request, reply) => {
|
||||||
|
const user = request.user as JwtPayload;
|
||||||
|
const { password, privateKey, ...rest } = request.body;
|
||||||
|
|
||||||
|
const profile = await fastify.prisma.profile.create({
|
||||||
|
data: {
|
||||||
|
...rest,
|
||||||
|
encryptedPassword: password ? encrypt(password) : null,
|
||||||
|
privateKey: privateKey ? encrypt(privateKey) : null,
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
protocol: true,
|
||||||
|
username: true,
|
||||||
|
domain: true,
|
||||||
|
clipboardEnabled: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return reply.status(201).send(profile);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/profiles/:id
|
||||||
|
fastify.patch<{ Params: { id: string }; Body: Partial<ProfileBody> }>(
|
||||||
|
'/:id',
|
||||||
|
async (request, reply) => {
|
||||||
|
const user = request.user as JwtPayload;
|
||||||
|
const { id } = request.params;
|
||||||
|
const { password, privateKey, ...rest } = request.body;
|
||||||
|
|
||||||
|
const existing = await fastify.prisma.profile.findFirst({
|
||||||
|
where: { id, userId: user.id },
|
||||||
|
});
|
||||||
|
if (!existing) return reply.status(404).send({ error: 'Profile not found' });
|
||||||
|
|
||||||
|
const updated = await fastify.prisma.profile.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...rest,
|
||||||
|
...(password !== undefined && {
|
||||||
|
encryptedPassword: password ? encrypt(password) : null,
|
||||||
|
}),
|
||||||
|
...(privateKey !== undefined && {
|
||||||
|
privateKey: privateKey ? encrypt(privateKey) : null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
username: true,
|
||||||
|
domain: true,
|
||||||
|
clipboardEnabled: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return reply.send(updated);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /api/profiles/: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.profile.findFirst({
|
||||||
|
where: { id, userId: user.id },
|
||||||
|
});
|
||||||
|
if (!existing) return reply.status(404).send({ error: 'Profile not found' });
|
||||||
|
|
||||||
|
await fastify.prisma.profile.delete({ where: { id } });
|
||||||
|
return reply.status(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
47
backend/src/utils/resolveCredentials.ts
Normal file
47
backend/src/utils/resolveCredentials.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { decrypt } from './encryption';
|
||||||
|
|
||||||
|
interface ProfileData {
|
||||||
|
username: string | null;
|
||||||
|
encryptedPassword: string | null;
|
||||||
|
privateKey: string | null;
|
||||||
|
domain: string | null;
|
||||||
|
clipboardEnabled: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConnectionWithProfile {
|
||||||
|
username: string;
|
||||||
|
encryptedPassword: string | null;
|
||||||
|
privateKey: string | null;
|
||||||
|
domain: string | null;
|
||||||
|
clipboardEnabled: boolean;
|
||||||
|
profile: ProfileData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedCredentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
privateKey: string | null;
|
||||||
|
domain: string;
|
||||||
|
clipboardEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCredentials(
|
||||||
|
connection: ConnectionWithProfile
|
||||||
|
): ResolvedCredentials {
|
||||||
|
const profile = connection.profile;
|
||||||
|
return {
|
||||||
|
username: connection.username || profile?.username || '',
|
||||||
|
password: connection.encryptedPassword
|
||||||
|
? decrypt(connection.encryptedPassword)
|
||||||
|
: profile?.encryptedPassword
|
||||||
|
? decrypt(profile.encryptedPassword)
|
||||||
|
: '',
|
||||||
|
privateKey: connection.privateKey
|
||||||
|
? decrypt(connection.privateKey)
|
||||||
|
: profile?.privateKey
|
||||||
|
? decrypt(profile.privateKey)
|
||||||
|
: null,
|
||||||
|
domain: connection.domain || profile?.domain || '',
|
||||||
|
clipboardEnabled: connection.clipboardEnabled ?? profile?.clipboardEnabled ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
import { SocketStream } from '@fastify/websocket';
|
import { SocketStream } from '@fastify/websocket';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import { decrypt } from '../utils/encryption';
|
import { resolveCredentials } from '../utils/resolveCredentials';
|
||||||
|
|
||||||
interface JwtPayload {
|
interface JwtPayload {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,6 +36,7 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
|||||||
const { connectionId } = request.params as { connectionId: string };
|
const { connectionId } = request.params as { connectionId: string };
|
||||||
const conn = await fastify.prisma.connection.findFirst({
|
const conn = await fastify.prisma.connection.findFirst({
|
||||||
where: { id: connectionId, userId },
|
where: { id: connectionId, userId },
|
||||||
|
include: { profile: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!conn) {
|
if (!conn) {
|
||||||
@@ -44,10 +45,10 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rdpdUrl = process.env.RDPD_URL || 'ws://localhost:7777';
|
const rdpdUrl = process.env.RDPD_URL || 'ws://localhost:7777';
|
||||||
const decryptedPassword = conn.encryptedPassword ? decrypt(conn.encryptedPassword) : '';
|
const creds = resolveCredentials(conn);
|
||||||
|
|
||||||
fastify.log.info(
|
fastify.log.info(
|
||||||
{ host: conn.host, port: conn.port, user: conn.username, rdpdUrl },
|
{ host: conn.host, port: conn.port, user: creds.username, rdpdUrl },
|
||||||
'RDP: connecting to rdpd'
|
'RDP: connecting to rdpd'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -71,12 +72,12 @@ export async function rdpWebsocket(fastify: FastifyInstance) {
|
|||||||
type: 'connect',
|
type: 'connect',
|
||||||
host: conn.host,
|
host: conn.host,
|
||||||
port: conn.port,
|
port: conn.port,
|
||||||
username: conn.username,
|
username: creds.username,
|
||||||
password: decryptedPassword,
|
password: creds.password,
|
||||||
domain: conn.domain || '',
|
domain: creds.domain,
|
||||||
width: 1280,
|
width: 1280,
|
||||||
height: 720,
|
height: 720,
|
||||||
clipboard: conn.clipboardEnabled !== false,
|
clipboard: creds.clipboardEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
rdpd.send(connectMsg);
|
rdpd.send(connectMsg);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { FastifyInstance, FastifyRequest } from 'fastify';
|
|||||||
import { SocketStream } from '@fastify/websocket';
|
import { SocketStream } from '@fastify/websocket';
|
||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import { Client as SshClient, ConnectConfig } from 'ssh2';
|
import { Client as SshClient, ConnectConfig } from 'ssh2';
|
||||||
import { decrypt } from '../utils/encryption';
|
import { resolveCredentials } from '../utils/resolveCredentials';
|
||||||
|
|
||||||
interface JwtPayload {
|
interface JwtPayload {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,6 +39,7 @@ export async function sshWebsocket(fastify: FastifyInstance) {
|
|||||||
const { connectionId } = request.params as { connectionId: string };
|
const { connectionId } = request.params as { connectionId: string };
|
||||||
const conn = await fastify.prisma.connection.findFirst({
|
const conn = await fastify.prisma.connection.findFirst({
|
||||||
where: { id: connectionId, userId },
|
where: { id: connectionId, userId },
|
||||||
|
include: { profile: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!conn) {
|
if (!conn) {
|
||||||
@@ -47,17 +48,18 @@ export async function sshWebsocket(fastify: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Build SSH config ---
|
// --- Build SSH config ---
|
||||||
|
const creds = resolveCredentials(conn);
|
||||||
const sshConfig: ConnectConfig = {
|
const sshConfig: ConnectConfig = {
|
||||||
host: conn.host,
|
host: conn.host,
|
||||||
port: conn.port,
|
port: conn.port,
|
||||||
username: conn.username,
|
username: creds.username,
|
||||||
readyTimeout: 10_000,
|
readyTimeout: 10_000,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (conn.privateKey) {
|
if (creds.privateKey) {
|
||||||
sshConfig.privateKey = conn.privateKey;
|
sshConfig.privateKey = creds.privateKey;
|
||||||
} else if (conn.encryptedPassword) {
|
} else if (creds.password) {
|
||||||
sshConfig.password = decrypt(conn.encryptedPassword);
|
sshConfig.password = creds.password;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Open SSH session ---
|
// --- Open SSH session ---
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Connection, ConnectionFormValues, Folder, User } from '../types';
|
import { Connection, ConnectionFormValues, Folder, Profile, ProfileFormValues, User } from '../types';
|
||||||
|
|
||||||
const api = axios.create({ baseURL: '/api' });
|
const api = axios.create({ baseURL: '/api' });
|
||||||
|
|
||||||
@@ -33,4 +33,12 @@ export const apiConnectionUpdate = (id: string, data: Partial<ConnectionFormValu
|
|||||||
api.patch<Connection>(`/connections/${id}`, data);
|
api.patch<Connection>(`/connections/${id}`, data);
|
||||||
export const apiConnectionDelete = (id: string) => api.delete(`/connections/${id}`);
|
export const apiConnectionDelete = (id: string) => api.delete(`/connections/${id}`);
|
||||||
|
|
||||||
|
// Profiles
|
||||||
|
export const apiProfileList = () => api.get<Profile[]>('/profiles');
|
||||||
|
export const apiProfileCreate = (data: ProfileFormValues) =>
|
||||||
|
api.post<Profile>('/profiles', data);
|
||||||
|
export const apiProfileUpdate = (id: string, data: Partial<ProfileFormValues>) =>
|
||||||
|
api.patch<Profile>(`/profiles/${id}`, data);
|
||||||
|
export const apiProfileDelete = (id: string) => api.delete(`/profiles/${id}`);
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import {
|
|||||||
Row,
|
Row,
|
||||||
Col,
|
Col,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { Connection, ConnectionFormValues, Folder } from '../../types';
|
import { Connection, ConnectionFormValues, Folder, Profile } from '../../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
connection?: Connection | null;
|
connection?: Connection | null;
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
|
profiles: Profile[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (values: ConnectionFormValues, id?: string) => Promise<void>;
|
onSave: (values: ConnectionFormValues, id?: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -29,11 +30,13 @@ export const ConnectionModal: React.FC<Props> = ({
|
|||||||
open,
|
open,
|
||||||
connection,
|
connection,
|
||||||
folders,
|
folders,
|
||||||
|
profiles,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}) => {
|
}) => {
|
||||||
const [form] = Form.useForm<ConnectionFormValues>();
|
const [form] = Form.useForm<ConnectionFormValues>();
|
||||||
const protocol = Form.useWatch('protocol', form);
|
const protocol = Form.useWatch('protocol', form);
|
||||||
|
const profileId = Form.useWatch('profileId', form);
|
||||||
const isEdit = !!connection?.id;
|
const isEdit = !!connection?.id;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,6 +54,7 @@ export const ConnectionModal: React.FC<Props> = ({
|
|||||||
notes: connection.notes ?? undefined,
|
notes: connection.notes ?? undefined,
|
||||||
clipboardEnabled: connection.clipboardEnabled !== false,
|
clipboardEnabled: connection.clipboardEnabled !== false,
|
||||||
folderId: connection.folderId ?? null,
|
folderId: connection.folderId ?? null,
|
||||||
|
profileId: connection.profileId ?? null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
@@ -61,6 +65,7 @@ export const ConnectionModal: React.FC<Props> = ({
|
|||||||
|
|
||||||
const handleProtocolChange = (value: 'ssh' | 'rdp') => {
|
const handleProtocolChange = (value: 'ssh' | 'rdp') => {
|
||||||
form.setFieldValue('port', value === 'ssh' ? 22 : 3389);
|
form.setFieldValue('port', value === 'ssh' ? 22 : 3389);
|
||||||
|
form.setFieldValue('profileId', null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOk = async () => {
|
const handleOk = async () => {
|
||||||
@@ -140,12 +145,20 @@ export const ConnectionModal: React.FC<Props> = ({
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Profile" name="profileId">
|
||||||
|
<Select allowClear placeholder="No profile">
|
||||||
|
{profiles.filter((p) => p.protocol === protocol).map((p) => (
|
||||||
|
<Option key={p.id} value={p.id}>{p.name}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="Username"
|
label="Username"
|
||||||
name="username"
|
name="username"
|
||||||
rules={[{ required: true, message: 'Required' }]}
|
rules={[{ required: !profileId, message: 'Required (or select a profile)' }]}
|
||||||
>
|
>
|
||||||
<Input placeholder="root" />
|
<Input placeholder={profileId ? 'From profile' : 'root'} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Layout, Typography, Dropdown, Avatar, Space } from 'antd';
|
import { Layout, Typography, Dropdown, Avatar, Space } from 'antd';
|
||||||
import { UserOutlined, LogoutOutlined } from '@ant-design/icons';
|
import { UserOutlined, LogoutOutlined, IdcardOutlined } from '@ant-design/icons';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import { useStore } from '../../store';
|
import { useStore } from '../../store';
|
||||||
|
import { ProfileManager } from '../Profiles/ProfileManager';
|
||||||
|
|
||||||
const { Header } = Layout;
|
const { Header } = Layout;
|
||||||
|
|
||||||
@@ -11,6 +12,13 @@ export const TopNav: React.FC = () => {
|
|||||||
const logout = useStore((s) => s.logout);
|
const logout = useStore((s) => s.logout);
|
||||||
|
|
||||||
const menuItems: MenuProps['items'] = [
|
const menuItems: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'profiles',
|
||||||
|
icon: <IdcardOutlined />,
|
||||||
|
label: 'Connection Profiles',
|
||||||
|
onClick: () => useStore.getState().setProfileManagerOpen(true),
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
key: 'logout',
|
key: 'logout',
|
||||||
icon: <LogoutOutlined />,
|
icon: <LogoutOutlined />,
|
||||||
@@ -20,6 +28,7 @@ export const TopNav: React.FC = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -42,5 +51,8 @@ export const TopNav: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
|
<ProfileManager />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
226
frontend/src/components/Profiles/ProfileManager.tsx
Normal file
226
frontend/src/components/Profiles/ProfileManager.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
Table,
|
||||||
|
Button,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Switch,
|
||||||
|
Tag,
|
||||||
|
Space,
|
||||||
|
Popconfirm,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { useStore } from '../../store';
|
||||||
|
import { apiProfileList, apiProfileCreate, apiProfileUpdate, apiProfileDelete } from '../../api/client';
|
||||||
|
import { Profile, ProfileFormValues } from '../../types';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
export const ProfileManager: React.FC = () => {
|
||||||
|
const open = useStore((s) => s.profileManagerOpen);
|
||||||
|
const setOpen = useStore((s) => s.setProfileManagerOpen);
|
||||||
|
const profiles = useStore((s) => s.profiles);
|
||||||
|
const setProfiles = useStore((s) => s.setProfiles);
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Profile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [form] = Form.useForm<ProfileFormValues>();
|
||||||
|
const protocol = Form.useWatch('protocol', form);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiProfileList();
|
||||||
|
setProfiles(res.data);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to load profiles');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) refresh();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({ protocol: 'ssh', clipboardEnabled: true });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (profile: Profile) => {
|
||||||
|
setEditing(profile);
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: profile.name,
|
||||||
|
protocol: profile.protocol,
|
||||||
|
username: profile.username ?? undefined,
|
||||||
|
domain: profile.domain ?? undefined,
|
||||||
|
clipboardEnabled: profile.clipboardEnabled !== false,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setLoading(true);
|
||||||
|
if (editing) {
|
||||||
|
await apiProfileUpdate(editing.id, values);
|
||||||
|
} else {
|
||||||
|
await apiProfileCreate(values);
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditing(null);
|
||||||
|
form.resetFields();
|
||||||
|
await refresh();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to save profile');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await apiProfileDelete(id);
|
||||||
|
await refresh();
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to delete profile');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'Name', dataIndex: 'name', key: 'name' },
|
||||||
|
{
|
||||||
|
title: 'Type',
|
||||||
|
dataIndex: 'protocol',
|
||||||
|
key: 'protocol',
|
||||||
|
width: 80,
|
||||||
|
render: (v: string) => (
|
||||||
|
<Tag color={v === 'rdp' ? 'blue' : 'green'}>{v.toUpperCase()}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: 'Username', dataIndex: 'username', key: 'username', render: (v: string | null) => v || '-' },
|
||||||
|
{ title: 'Domain', dataIndex: 'domain', key: 'domain', render: (v: string | null) => v || '-' },
|
||||||
|
{
|
||||||
|
title: 'Password',
|
||||||
|
dataIndex: 'hasPassword',
|
||||||
|
key: 'hasPassword',
|
||||||
|
width: 90,
|
||||||
|
render: (v: boolean) => v ? 'Set' : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
key: 'actions',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, record: Profile) => (
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleOpenEdit(record)}
|
||||||
|
/>
|
||||||
|
<Popconfirm
|
||||||
|
title="Delete this profile?"
|
||||||
|
description="Connections using it will keep working but lose their profile reference."
|
||||||
|
onConfirm={() => handleDelete(record.id)}
|
||||||
|
>
|
||||||
|
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const isEdit = !!editing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Drawer
|
||||||
|
title="Connection Profiles"
|
||||||
|
open={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
width={720}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleOpenCreate}>
|
||||||
|
New Profile
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
dataSource={profiles}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={isEdit ? `Edit Profile — ${editing?.name}` : 'New Profile'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => { setModalOpen(false); setEditing(null); }}
|
||||||
|
width={480}
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => { setModalOpen(false); setEditing(null); }}>Cancel</Button>
|
||||||
|
<Button type="primary" loading={loading} onClick={handleSave}>
|
||||||
|
{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="Production Servers" autoFocus />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Protocol" name="protocol" rules={[{ required: true, message: 'Required' }]}>
|
||||||
|
<Select disabled={isEdit}>
|
||||||
|
<Option value="ssh">SSH</Option>
|
||||||
|
<Option value="rdp">RDP</Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Username" name="username">
|
||||||
|
<Input placeholder="admin" />
|
||||||
|
</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={3} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{protocol === 'rdp' && (
|
||||||
|
<Form.Item label="Domain" name="domain">
|
||||||
|
<Input placeholder="CORP" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{protocol === 'rdp' && (
|
||||||
|
<Form.Item label="Clipboard" name="clipboardEnabled" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="Enabled" unCheckedChildren="Disabled" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { useStore } from '../../store';
|
import { useStore } from '../../store';
|
||||||
import { apiConnectionUpdate } from '../../api/client';
|
import { apiConnectionUpdate } from '../../api/client';
|
||||||
import { ConnectionFormValues, Folder } from '../../types';
|
import { ConnectionFormValues, Folder, Profile } from '../../types';
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -41,12 +41,14 @@ export const ConnectionProperties: React.FC = () => {
|
|||||||
const selectedConnectionId = useStore((s) => s.selectedConnectionId);
|
const selectedConnectionId = useStore((s) => s.selectedConnectionId);
|
||||||
const connections = useStore((s) => s.connections);
|
const connections = useStore((s) => s.connections);
|
||||||
const folders = useStore((s) => s.folders);
|
const folders = useStore((s) => s.folders);
|
||||||
|
const profiles = useStore((s) => s.profiles);
|
||||||
const setConnections = useStore((s) => s.setConnections);
|
const setConnections = useStore((s) => s.setConnections);
|
||||||
const [form] = Form.useForm<ConnectionFormValues>();
|
const [form] = Form.useForm<ConnectionFormValues>();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const connection = connections.find((c) => c.id === selectedConnectionId) ?? null;
|
const connection = connections.find((c) => c.id === selectedConnectionId) ?? null;
|
||||||
const protocol = Form.useWatch('protocol', form);
|
const protocol = Form.useWatch('protocol', form);
|
||||||
|
const profileId = Form.useWatch('profileId', form);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (connection) {
|
if (connection) {
|
||||||
@@ -62,6 +64,7 @@ export const ConnectionProperties: React.FC = () => {
|
|||||||
notes: connection.notes ?? undefined,
|
notes: connection.notes ?? undefined,
|
||||||
clipboardEnabled: connection.clipboardEnabled !== false,
|
clipboardEnabled: connection.clipboardEnabled !== false,
|
||||||
folderId: connection.folderId ?? null,
|
folderId: connection.folderId ?? null,
|
||||||
|
profileId: connection.profileId ?? null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
@@ -138,8 +141,16 @@ export const ConnectionProperties: React.FC = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="Username" name="username" rules={[{ required: true }]}>
|
<Form.Item label="Profile" name="profileId">
|
||||||
<Input />
|
<Select allowClear placeholder="No profile">
|
||||||
|
{profiles.filter((p) => p.protocol === protocol).map((p) => (
|
||||||
|
<Option key={p.id} value={p.id}>{p.name}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="Username" name="username" rules={[{ required: !profileId, message: 'Required (or select a profile)' }]}>
|
||||||
|
<Input placeholder={profileId ? 'From profile' : undefined} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="Password" name="password" extra="Leave blank to keep current">
|
<Form.Item label="Password" name="password" extra="Leave blank to keep current">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
apiConnectionCreate,
|
apiConnectionCreate,
|
||||||
apiConnectionDelete,
|
apiConnectionDelete,
|
||||||
apiConnectionUpdate,
|
apiConnectionUpdate,
|
||||||
|
apiProfileList,
|
||||||
} from '../../api/client';
|
} from '../../api/client';
|
||||||
import { Connection, ConnectionFormValues, Folder } from '../../types';
|
import { Connection, ConnectionFormValues, Folder } from '../../types';
|
||||||
import { ConnectionModal } from '../Modals/ConnectionModal';
|
import { ConnectionModal } from '../Modals/ConnectionModal';
|
||||||
@@ -86,6 +87,8 @@ export const ConnectionTree: React.FC = () => {
|
|||||||
const connections = useStore((s) => s.connections);
|
const connections = useStore((s) => s.connections);
|
||||||
const setFolders = useStore((s) => s.setFolders);
|
const setFolders = useStore((s) => s.setFolders);
|
||||||
const setConnections = useStore((s) => s.setConnections);
|
const setConnections = useStore((s) => s.setConnections);
|
||||||
|
const profiles = useStore((s) => s.profiles);
|
||||||
|
const setProfiles = useStore((s) => s.setProfiles);
|
||||||
const openSession = useStore((s) => s.openSession);
|
const openSession = useStore((s) => s.openSession);
|
||||||
const selectedConnectionId = useStore((s) => s.selectedConnectionId);
|
const selectedConnectionId = useStore((s) => s.selectedConnectionId);
|
||||||
const setSelectedConnection = useStore((s) => s.setSelectedConnection);
|
const setSelectedConnection = useStore((s) => s.setSelectedConnection);
|
||||||
@@ -159,15 +162,16 @@ export const ConnectionTree: React.FC = () => {
|
|||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [fRes, cRes] = await Promise.all([apiFolderList(), apiConnectionList()]);
|
const [fRes, cRes, pRes] = await Promise.all([apiFolderList(), apiConnectionList(), apiProfileList()]);
|
||||||
setFolders(fRes.data);
|
setFolders(fRes.data);
|
||||||
setConnections(cRes.data);
|
setConnections(cRes.data);
|
||||||
|
setProfiles(pRes.data);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to load connections');
|
message.error('Failed to load connections');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [setFolders, setConnections]);
|
}, [setFolders, setConnections, setProfiles]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
@@ -468,6 +472,7 @@ export const ConnectionTree: React.FC = () => {
|
|||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
connection={editingConnection}
|
connection={editingConnection}
|
||||||
folders={folders}
|
folders={folders}
|
||||||
|
profiles={profiles}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
setEditingConnection(null);
|
setEditingConnection(null);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { Connection, Folder, Session, User } from '../types';
|
import { Connection, Folder, Profile, Session, User } from '../types';
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// Auth
|
// Auth
|
||||||
@@ -14,6 +14,12 @@ interface AppState {
|
|||||||
setFolders: (folders: Folder[]) => void;
|
setFolders: (folders: Folder[]) => void;
|
||||||
setConnections: (connections: Connection[]) => void;
|
setConnections: (connections: Connection[]) => void;
|
||||||
|
|
||||||
|
// Profiles
|
||||||
|
profiles: Profile[];
|
||||||
|
setProfiles: (profiles: Profile[]) => void;
|
||||||
|
profileManagerOpen: boolean;
|
||||||
|
setProfileManagerOpen: (open: boolean) => void;
|
||||||
|
|
||||||
// Selected connection (properties panel)
|
// Selected connection (properties panel)
|
||||||
selectedConnectionId: string | null;
|
selectedConnectionId: string | null;
|
||||||
setSelectedConnection: (id: string | null) => void;
|
setSelectedConnection: (id: string | null) => void;
|
||||||
@@ -56,6 +62,12 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
setFolders: (folders) => set({ folders }),
|
setFolders: (folders) => set({ folders }),
|
||||||
setConnections: (connections) => set({ connections }),
|
setConnections: (connections) => set({ connections }),
|
||||||
|
|
||||||
|
// Profiles
|
||||||
|
profiles: [],
|
||||||
|
setProfiles: (profiles) => set({ profiles }),
|
||||||
|
profileManagerOpen: false,
|
||||||
|
setProfileManagerOpen: (open) => set({ profileManagerOpen: open }),
|
||||||
|
|
||||||
// Selected connection
|
// Selected connection
|
||||||
selectedConnectionId: null,
|
selectedConnectionId: null,
|
||||||
setSelectedConnection: (id) => set({ selectedConnectionId: id }),
|
setSelectedConnection: (id) => set({ selectedConnectionId: id }),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface Connection {
|
|||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
clipboardEnabled?: boolean;
|
clipboardEnabled?: boolean;
|
||||||
folderId?: string | null;
|
folderId?: string | null;
|
||||||
|
profileId?: string | null;
|
||||||
privateKey?: string | null;
|
privateKey?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -40,6 +41,29 @@ export interface ConnectionFormValues {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
clipboardEnabled?: boolean;
|
clipboardEnabled?: boolean;
|
||||||
folderId?: string | null;
|
folderId?: string | null;
|
||||||
|
profileId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
protocol: 'ssh' | 'rdp';
|
||||||
|
username?: string | null;
|
||||||
|
domain?: string | null;
|
||||||
|
clipboardEnabled?: boolean | null;
|
||||||
|
hasPassword?: boolean;
|
||||||
|
hasPrivateKey?: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileFormValues {
|
||||||
|
name: string;
|
||||||
|
protocol: 'ssh' | 'rdp';
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
domain?: string;
|
||||||
|
clipboardEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
|
|||||||
Reference in New Issue
Block a user