connection profiles and ssh key encryption
This commit is contained in:
@@ -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<ConnectionFormValu
|
||||
api.patch<Connection>(`/connections/${id}`, data);
|
||||
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;
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
@@ -29,11 +30,13 @@ export const ConnectionModal: React.FC<Props> = ({
|
||||
open,
|
||||
connection,
|
||||
folders,
|
||||
profiles,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const [form] = Form.useForm<ConnectionFormValues>();
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
|
||||
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<Props> = ({
|
||||
</Select>
|
||||
</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
|
||||
label="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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
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 { useStore } from '../../store';
|
||||
import { ProfileManager } from '../Profiles/ProfileManager';
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
@@ -11,6 +12,13 @@ export const TopNav: React.FC = () => {
|
||||
const logout = useStore((s) => s.logout);
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'profiles',
|
||||
icon: <IdcardOutlined />,
|
||||
label: 'Connection Profiles',
|
||||
onClick: () => useStore.getState().setProfileManagerOpen(true),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
@@ -20,6 +28,7 @@ export const TopNav: React.FC = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -42,5 +51,8 @@ export const TopNav: React.FC = () => {
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</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';
|
||||
import { useStore } from '../../store';
|
||||
import { apiConnectionUpdate } from '../../api/client';
|
||||
import { ConnectionFormValues, Folder } from '../../types';
|
||||
import { ConnectionFormValues, Folder, Profile } from '../../types';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
@@ -41,12 +41,14 @@ export const ConnectionProperties: React.FC = () => {
|
||||
const selectedConnectionId = useStore((s) => s.selectedConnectionId);
|
||||
const connections = useStore((s) => s.connections);
|
||||
const folders = useStore((s) => s.folders);
|
||||
const profiles = useStore((s) => s.profiles);
|
||||
const setConnections = useStore((s) => s.setConnections);
|
||||
const [form] = Form.useForm<ConnectionFormValues>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const connection = connections.find((c) => c.id === selectedConnectionId) ?? null;
|
||||
const protocol = Form.useWatch('protocol', form);
|
||||
const profileId = Form.useWatch('profileId', form);
|
||||
|
||||
useEffect(() => {
|
||||
if (connection) {
|
||||
@@ -62,6 +64,7 @@ export const ConnectionProperties: React.FC = () => {
|
||||
notes: connection.notes ?? undefined,
|
||||
clipboardEnabled: connection.clipboardEnabled !== false,
|
||||
folderId: connection.folderId ?? null,
|
||||
profileId: connection.profileId ?? null,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
@@ -138,8 +141,16 @@ export const ConnectionProperties: React.FC = () => {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Username" name="username" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
<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 label="Username" name="username" rules={[{ required: !profileId, message: 'Required (or select a profile)' }]}>
|
||||
<Input placeholder={profileId ? 'From profile' : undefined} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Password" name="password" extra="Leave blank to keep current">
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
apiConnectionCreate,
|
||||
apiConnectionDelete,
|
||||
apiConnectionUpdate,
|
||||
apiProfileList,
|
||||
} from '../../api/client';
|
||||
import { Connection, ConnectionFormValues, Folder } from '../../types';
|
||||
import { ConnectionModal } from '../Modals/ConnectionModal';
|
||||
@@ -86,6 +87,8 @@ export const ConnectionTree: React.FC = () => {
|
||||
const connections = useStore((s) => s.connections);
|
||||
const setFolders = useStore((s) => s.setFolders);
|
||||
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 selectedConnectionId = useStore((s) => s.selectedConnectionId);
|
||||
const setSelectedConnection = useStore((s) => s.setSelectedConnection);
|
||||
@@ -159,15 +162,16 @@ export const ConnectionTree: React.FC = () => {
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [fRes, cRes] = await Promise.all([apiFolderList(), apiConnectionList()]);
|
||||
const [fRes, cRes, pRes] = await Promise.all([apiFolderList(), apiConnectionList(), apiProfileList()]);
|
||||
setFolders(fRes.data);
|
||||
setConnections(cRes.data);
|
||||
setProfiles(pRes.data);
|
||||
} catch {
|
||||
message.error('Failed to load connections');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setFolders, setConnections]);
|
||||
}, [setFolders, setConnections, setProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
@@ -468,6 +472,7 @@ export const ConnectionTree: React.FC = () => {
|
||||
open={modalOpen}
|
||||
connection={editingConnection}
|
||||
folders={folders}
|
||||
profiles={profiles}
|
||||
onClose={() => {
|
||||
setModalOpen(false);
|
||||
setEditingConnection(null);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { Connection, Folder, Session, User } from '../types';
|
||||
import { Connection, Folder, Profile, Session, User } from '../types';
|
||||
|
||||
interface AppState {
|
||||
// Auth
|
||||
@@ -14,6 +14,12 @@ interface AppState {
|
||||
setFolders: (folders: Folder[]) => void;
|
||||
setConnections: (connections: Connection[]) => void;
|
||||
|
||||
// Profiles
|
||||
profiles: Profile[];
|
||||
setProfiles: (profiles: Profile[]) => void;
|
||||
profileManagerOpen: boolean;
|
||||
setProfileManagerOpen: (open: boolean) => void;
|
||||
|
||||
// Selected connection (properties panel)
|
||||
selectedConnectionId: string | null;
|
||||
setSelectedConnection: (id: string | null) => void;
|
||||
@@ -56,6 +62,12 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
setFolders: (folders) => set({ folders }),
|
||||
setConnections: (connections) => set({ connections }),
|
||||
|
||||
// Profiles
|
||||
profiles: [],
|
||||
setProfiles: (profiles) => set({ profiles }),
|
||||
profileManagerOpen: false,
|
||||
setProfileManagerOpen: (open) => set({ profileManagerOpen: open }),
|
||||
|
||||
// Selected connection
|
||||
selectedConnectionId: null,
|
||||
setSelectedConnection: (id) => set({ selectedConnectionId: id }),
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface Connection {
|
||||
notes?: string | null;
|
||||
clipboardEnabled?: boolean;
|
||||
folderId?: string | null;
|
||||
profileId?: string | null;
|
||||
privateKey?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -40,6 +41,29 @@ export interface ConnectionFormValues {
|
||||
notes?: string;
|
||||
clipboardEnabled?: boolean;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user