connection profiles and ssh key encryption

This commit is contained in:
felixg
2026-03-01 12:04:38 +01:00
parent 7e3a1ceef4
commit 93bf9ab70d
17 changed files with 621 additions and 28 deletions

View File

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

View File

@@ -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';

View File

@@ -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")
}

View File

@@ -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' });
} }

View File

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

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

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

View File

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

View File

@@ -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 ---

View File

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

View File

@@ -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

View File

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

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

View File

@@ -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">

View File

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

View File

@@ -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 }),

View File

@@ -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 {