From 93bf9ab70d635e3b451fae2c94265f9706cb9871 Mon Sep 17 00:00:00 2001 From: felixg Date: Sun, 1 Mar 2026 12:04:38 +0100 Subject: [PATCH] connection profiles and ssh key encryption --- .../20260301100000_add_profiles/migration.sql | 23 ++ .../migration.sql | 2 + backend/prisma/schema.prisma | 20 ++ backend/src/index.ts | 24 ++ backend/src/routes/connections.ts | 20 +- backend/src/routes/profiles.ts | 151 ++++++++++++ backend/src/utils/resolveCredentials.ts | 47 ++++ backend/src/websocket/rdp.ts | 15 +- backend/src/websocket/ssh.ts | 14 +- frontend/src/api/client.ts | 10 +- .../src/components/Modals/ConnectionModal.tsx | 19 +- frontend/src/components/Nav/TopNav.tsx | 14 +- .../components/Profiles/ProfileManager.tsx | 226 ++++++++++++++++++ .../Sidebar/ConnectionProperties.tsx | 17 +- .../src/components/Sidebar/ConnectionTree.tsx | 9 +- frontend/src/store/index.ts | 14 +- frontend/src/types/index.ts | 24 ++ 17 files changed, 621 insertions(+), 28 deletions(-) create mode 100644 backend/prisma/migrations/20260301100000_add_profiles/migration.sql create mode 100644 backend/prisma/migrations/20260301110000_profile_protocol/migration.sql create mode 100644 backend/src/routes/profiles.ts create mode 100644 backend/src/utils/resolveCredentials.ts create mode 100644 frontend/src/components/Profiles/ProfileManager.tsx diff --git a/backend/prisma/migrations/20260301100000_add_profiles/migration.sql b/backend/prisma/migrations/20260301100000_add_profiles/migration.sql new file mode 100644 index 0000000..215af09 --- /dev/null +++ b/backend/prisma/migrations/20260301100000_add_profiles/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20260301110000_profile_protocol/migration.sql b/backend/prisma/migrations/20260301110000_profile_protocol/migration.sql new file mode 100644 index 0000000..45d13f5 --- /dev/null +++ b/backend/prisma/migrations/20260301110000_profile_protocol/migration.sql @@ -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'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b27491c..08859d5 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -15,6 +15,7 @@ model User { createdAt DateTime @default(now()) folders Folder[] connections Connection[] + profiles Profile[] @@map("users") } @@ -48,9 +49,28 @@ model Connection { clipboardEnabled Boolean @default(true) folderId String? folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull) + profileId String? + profile Profile? @relation(fields: [profileId], references: [id], onDelete: SetNull) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@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") +} diff --git a/backend/src/index.ts b/backend/src/index.ts index c8bb05c..e5ef9e3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,8 +6,10 @@ import prismaPlugin from './plugins/prisma'; import { authRoutes } from './routes/auth'; import { folderRoutes } from './routes/folders'; import { connectionRoutes } from './routes/connections'; +import { profileRoutes } from './routes/profiles'; import { sshWebsocket } from './websocket/ssh'; import { rdpWebsocket } from './websocket/rdp'; +import { encrypt } from './utils/encryption'; declare module 'fastify' { interface FastifyInstance { @@ -41,6 +43,7 @@ async function buildApp(): Promise { await fastify.register(authRoutes, { prefix: '/api/auth' }); await fastify.register(folderRoutes, { prefix: '/api/folders' }); await fastify.register(connectionRoutes, { prefix: '/api/connections' }); + await fastify.register(profileRoutes, { prefix: '/api/profiles' }); // WebSocket routes await fastify.register(sshWebsocket); @@ -49,8 +52,29 @@ async function buildApp(): Promise { 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() { const app = await buildApp(); + await migratePrivateKeys(app); const port = Number(process.env.PORT) || 3000; await app.listen({ port, host: '0.0.0.0' }); } diff --git a/backend/src/routes/connections.ts b/backend/src/routes/connections.ts index c4779c1..141bb94 100644 --- a/backend/src/routes/connections.ts +++ b/backend/src/routes/connections.ts @@ -1,5 +1,5 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { encrypt } from '../utils/encryption'; +import { encrypt, decrypt } from '../utils/encryption'; interface JwtPayload { id: string; @@ -19,6 +19,7 @@ interface ConnectionBody { notes?: string; clipboardEnabled?: boolean; folderId?: string | null; + profileId?: string | null; } export async function connectionRoutes(fastify: FastifyInstance) { @@ -42,6 +43,7 @@ export async function connectionRoutes(fastify: FastifyInstance) { notes: true, clipboardEnabled: true, folderId: true, + profileId: true, createdAt: true, // Do NOT expose encryptedPassword or privateKey in list }, @@ -66,25 +68,31 @@ export async function connectionRoutes(fastify: FastifyInstance) { notes: true, clipboardEnabled: true, folderId: true, + profileId: true, privateKey: true, createdAt: true, // still omit encryptedPassword }, }); 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 fastify.post<{ Body: ConnectionBody }>('/', async (request, reply) => { const user = request.user as JwtPayload; - const { password, ...rest } = request.body; + const { password, privateKey, ...rest } = request.body; const connection = await fastify.prisma.connection.create({ data: { ...rest, folderId: rest.folderId || null, + profileId: rest.profileId || null, encryptedPassword: password ? encrypt(password) : null, + privateKey: privateKey ? encrypt(privateKey) : null, userId: user.id, }, select: { @@ -99,6 +107,7 @@ export async function connectionRoutes(fastify: FastifyInstance) { notes: true, clipboardEnabled: true, folderId: true, + profileId: true, createdAt: true, }, }); @@ -111,7 +120,7 @@ export async function connectionRoutes(fastify: FastifyInstance) { async (request, reply) => { const user = request.user as JwtPayload; 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({ where: { id, userId: user.id }, @@ -123,7 +132,9 @@ export async function connectionRoutes(fastify: FastifyInstance) { data: { ...rest, ...(folderId !== undefined && { folderId: folderId ?? null }), + ...(profileId !== undefined && { profileId: profileId ?? null }), ...(password !== undefined && { encryptedPassword: password ? encrypt(password) : null }), + ...(privateKey !== undefined && { privateKey: privateKey ? encrypt(privateKey) : null }), }, select: { id: true, @@ -137,6 +148,7 @@ export async function connectionRoutes(fastify: FastifyInstance) { notes: true, clipboardEnabled: true, folderId: true, + profileId: true, createdAt: true, }, }); diff --git a/backend/src/routes/profiles.ts b/backend/src/routes/profiles.ts new file mode 100644 index 0000000..c19c109 --- /dev/null +++ b/backend/src/routes/profiles.ts @@ -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 }>( + '/: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(); + }); +} diff --git a/backend/src/utils/resolveCredentials.ts b/backend/src/utils/resolveCredentials.ts new file mode 100644 index 0000000..92120e6 --- /dev/null +++ b/backend/src/utils/resolveCredentials.ts @@ -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, + }; +} diff --git a/backend/src/websocket/rdp.ts b/backend/src/websocket/rdp.ts index 6be0c9b..b4265f0 100644 --- a/backend/src/websocket/rdp.ts +++ b/backend/src/websocket/rdp.ts @@ -1,7 +1,7 @@ import { FastifyInstance, FastifyRequest } from 'fastify'; import { SocketStream } from '@fastify/websocket'; import { WebSocket } from 'ws'; -import { decrypt } from '../utils/encryption'; +import { resolveCredentials } from '../utils/resolveCredentials'; interface JwtPayload { id: string; @@ -36,6 +36,7 @@ export async function rdpWebsocket(fastify: FastifyInstance) { const { connectionId } = request.params as { connectionId: string }; const conn = await fastify.prisma.connection.findFirst({ where: { id: connectionId, userId }, + include: { profile: true }, }); if (!conn) { @@ -44,10 +45,10 @@ export async function rdpWebsocket(fastify: FastifyInstance) { } const rdpdUrl = process.env.RDPD_URL || 'ws://localhost:7777'; - const decryptedPassword = conn.encryptedPassword ? decrypt(conn.encryptedPassword) : ''; + const creds = resolveCredentials(conn); 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' ); @@ -71,12 +72,12 @@ export async function rdpWebsocket(fastify: FastifyInstance) { type: 'connect', host: conn.host, port: conn.port, - username: conn.username, - password: decryptedPassword, - domain: conn.domain || '', + username: creds.username, + password: creds.password, + domain: creds.domain, width: 1280, height: 720, - clipboard: conn.clipboardEnabled !== false, + clipboard: creds.clipboardEnabled, }); rdpd.send(connectMsg); diff --git a/backend/src/websocket/ssh.ts b/backend/src/websocket/ssh.ts index 44c615b..c90e49a 100644 --- a/backend/src/websocket/ssh.ts +++ b/backend/src/websocket/ssh.ts @@ -2,7 +2,7 @@ import { FastifyInstance, FastifyRequest } from 'fastify'; import { SocketStream } from '@fastify/websocket'; import { WebSocket } from 'ws'; import { Client as SshClient, ConnectConfig } from 'ssh2'; -import { decrypt } from '../utils/encryption'; +import { resolveCredentials } from '../utils/resolveCredentials'; interface JwtPayload { id: string; @@ -39,6 +39,7 @@ export async function sshWebsocket(fastify: FastifyInstance) { const { connectionId } = request.params as { connectionId: string }; const conn = await fastify.prisma.connection.findFirst({ where: { id: connectionId, userId }, + include: { profile: true }, }); if (!conn) { @@ -47,17 +48,18 @@ export async function sshWebsocket(fastify: FastifyInstance) { } // --- Build SSH config --- + const creds = resolveCredentials(conn); const sshConfig: ConnectConfig = { host: conn.host, port: conn.port, - username: conn.username, + username: creds.username, readyTimeout: 10_000, }; - if (conn.privateKey) { - sshConfig.privateKey = conn.privateKey; - } else if (conn.encryptedPassword) { - sshConfig.password = decrypt(conn.encryptedPassword); + if (creds.privateKey) { + sshConfig.privateKey = creds.privateKey; + } else if (creds.password) { + sshConfig.password = creds.password; } // --- Open SSH session --- diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4a7dc47..5b72f96 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,5 @@ 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' }); @@ -33,4 +33,12 @@ export const apiConnectionUpdate = (id: string, data: Partial(`/connections/${id}`, data); export const apiConnectionDelete = (id: string) => api.delete(`/connections/${id}`); +// Profiles +export const apiProfileList = () => api.get('/profiles'); +export const apiProfileCreate = (data: ProfileFormValues) => + api.post('/profiles', data); +export const apiProfileUpdate = (id: string, data: Partial) => + api.patch(`/profiles/${id}`, data); +export const apiProfileDelete = (id: string) => api.delete(`/profiles/${id}`); + export default api; diff --git a/frontend/src/components/Modals/ConnectionModal.tsx b/frontend/src/components/Modals/ConnectionModal.tsx index 0b96eb2..36da56f 100644 --- a/frontend/src/components/Modals/ConnectionModal.tsx +++ b/frontend/src/components/Modals/ConnectionModal.tsx @@ -12,12 +12,13 @@ import { Row, Col, } from 'antd'; -import { Connection, ConnectionFormValues, Folder } from '../../types'; +import { Connection, ConnectionFormValues, Folder, Profile } from '../../types'; interface Props { open: boolean; connection?: Connection | null; folders: Folder[]; + profiles: Profile[]; onClose: () => void; onSave: (values: ConnectionFormValues, id?: string) => Promise; } @@ -29,11 +30,13 @@ export const ConnectionModal: React.FC = ({ open, connection, folders, + profiles, onClose, onSave, }) => { const [form] = Form.useForm(); const protocol = Form.useWatch('protocol', form); + const profileId = Form.useWatch('profileId', form); const isEdit = !!connection?.id; useEffect(() => { @@ -51,6 +54,7 @@ export const ConnectionModal: React.FC = ({ notes: connection.notes ?? undefined, clipboardEnabled: connection.clipboardEnabled !== false, folderId: connection.folderId ?? null, + profileId: connection.profileId ?? null, }); } else { form.resetFields(); @@ -61,6 +65,7 @@ export const ConnectionModal: React.FC = ({ const handleProtocolChange = (value: 'ssh' | 'rdp') => { form.setFieldValue('port', value === 'ssh' ? 22 : 3389); + form.setFieldValue('profileId', null); }; const handleOk = async () => { @@ -140,12 +145,20 @@ export const ConnectionModal: React.FC = ({ + + + + - + { const logout = useStore((s) => s.logout); const menuItems: MenuProps['items'] = [ + { + key: 'profiles', + icon: , + label: 'Connection Profiles', + onClick: () => useStore.getState().setProfileManagerOpen(true), + }, + { type: 'divider' }, { key: 'logout', icon: , @@ -20,6 +28,7 @@ export const TopNav: React.FC = () => { ]; return ( + <>
{
+ + + ); }; diff --git a/frontend/src/components/Profiles/ProfileManager.tsx b/frontend/src/components/Profiles/ProfileManager.tsx new file mode 100644 index 0000000..db1afba --- /dev/null +++ b/frontend/src/components/Profiles/ProfileManager.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [form] = Form.useForm(); + 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) => ( + {v.toUpperCase()} + ), + }, + { 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) => ( + + + } + > + + + + { setModalOpen(false); setEditing(null); }} + width={480} + footer={ + + + + + } + destroyOnClose + > +
+ + + + + + + + + + + + + + + + + {protocol === 'ssh' && ( + +