533 lines
16 KiB
TypeScript
533 lines
16 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Table,
|
|
Button,
|
|
Space,
|
|
Typography,
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
Select,
|
|
message,
|
|
Popconfirm,
|
|
Tag,
|
|
Card,
|
|
Row,
|
|
Col,
|
|
} from 'antd';
|
|
import {
|
|
PlusOutlined,
|
|
EditOutlined,
|
|
DeleteOutlined,
|
|
EyeOutlined,
|
|
CopyOutlined,
|
|
} from '@ant-design/icons';
|
|
import { apiService, Application, CreateApplicationRequest } from '../services/apiService';
|
|
import dayjs from 'dayjs';
|
|
|
|
const { Title, Text } = Typography;
|
|
const { Option } = Select;
|
|
|
|
const Applications: React.FC = () => {
|
|
const [applications, setApplications] = useState<Application[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [modalVisible, setModalVisible] = useState(false);
|
|
const [editingApp, setEditingApp] = useState<Application | null>(null);
|
|
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
|
const [selectedApp, setSelectedApp] = useState<Application | null>(null);
|
|
const [form] = Form.useForm();
|
|
|
|
useEffect(() => {
|
|
loadApplications();
|
|
}, []);
|
|
|
|
const loadApplications = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await apiService.getApplications();
|
|
setApplications(response.data);
|
|
} catch (error) {
|
|
console.error('Failed to load applications:', error);
|
|
message.error('Failed to load applications');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCreate = () => {
|
|
setEditingApp(null);
|
|
form.resetFields();
|
|
setModalVisible(true);
|
|
};
|
|
|
|
const handleEdit = (app: Application) => {
|
|
setEditingApp(app);
|
|
form.setFieldsValue({
|
|
app_id: app.app_id,
|
|
app_link: app.app_link,
|
|
type: app.type,
|
|
callback_url: app.callback_url,
|
|
token_prefix: app.token_prefix,
|
|
token_renewal_duration: formatDuration(app.token_renewal_duration),
|
|
max_token_duration: formatDuration(app.max_token_duration),
|
|
owner_type: app.owner.type,
|
|
owner_name: app.owner.name,
|
|
owner_owner: app.owner.owner,
|
|
});
|
|
setModalVisible(true);
|
|
};
|
|
|
|
const handleDelete = async (appId: string) => {
|
|
try {
|
|
await apiService.deleteApplication(appId);
|
|
message.success('Application deleted successfully');
|
|
loadApplications();
|
|
} catch (error) {
|
|
console.error('Failed to delete application:', error);
|
|
message.error('Failed to delete application');
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (values: any) => {
|
|
try {
|
|
const requestData: CreateApplicationRequest = {
|
|
app_id: values.app_id,
|
|
app_link: values.app_link,
|
|
type: values.type,
|
|
callback_url: values.callback_url,
|
|
token_prefix: values.token_prefix,
|
|
token_renewal_duration: values.token_renewal_duration,
|
|
max_token_duration: values.max_token_duration,
|
|
owner: {
|
|
type: values.owner_type,
|
|
name: values.owner_name,
|
|
owner: values.owner_owner,
|
|
},
|
|
};
|
|
|
|
if (editingApp) {
|
|
await apiService.updateApplication(editingApp.app_id, requestData);
|
|
message.success('Application updated successfully');
|
|
} else {
|
|
await apiService.createApplication(requestData);
|
|
message.success('Application created successfully');
|
|
}
|
|
|
|
setModalVisible(false);
|
|
loadApplications();
|
|
} catch (error) {
|
|
console.error('Failed to save application:', error);
|
|
message.error('Failed to save application');
|
|
}
|
|
};
|
|
|
|
const showDetails = (app: Application) => {
|
|
setSelectedApp(app);
|
|
setDetailModalVisible(true);
|
|
};
|
|
|
|
const copyToClipboard = (text: string) => {
|
|
navigator.clipboard.writeText(text);
|
|
message.success('Copied to clipboard');
|
|
};
|
|
|
|
const formatDuration = (nanoseconds: number): string => {
|
|
const hours = Math.floor(nanoseconds / (1000000000 * 60 * 60));
|
|
return `${hours}h`;
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: 'App ID',
|
|
dataIndex: 'app_id',
|
|
key: 'app_id',
|
|
render: (text: string) => <Text code>{text}</Text>,
|
|
},
|
|
{
|
|
title: 'App Link',
|
|
dataIndex: 'app_link',
|
|
key: 'app_link',
|
|
render: (text: string) => (
|
|
<a href={text} target="_blank" rel="noopener noreferrer">
|
|
{text}
|
|
</a>
|
|
),
|
|
},
|
|
{
|
|
title: 'Type',
|
|
dataIndex: 'type',
|
|
key: 'type',
|
|
render: (types: string[]) => (
|
|
<>
|
|
{types.map((type) => (
|
|
<Tag key={type} color={type === 'static' ? 'blue' : 'green'}>
|
|
{type.toUpperCase()}
|
|
</Tag>
|
|
))}
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
title: 'Token Prefix',
|
|
dataIndex: 'token_prefix',
|
|
key: 'token_prefix',
|
|
render: (prefix: string) => prefix ? <Text code>{prefix}</Text> : <Text type="secondary">Default</Text>,
|
|
},
|
|
{
|
|
title: 'Owner',
|
|
dataIndex: 'owner',
|
|
key: 'owner',
|
|
render: (owner: Application['owner']) => (
|
|
<div>
|
|
<div>{owner.name}</div>
|
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
|
{owner.type} • {owner.owner}
|
|
</Text>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: 'Created',
|
|
dataIndex: 'created_at',
|
|
key: 'created_at',
|
|
render: (date: string) => dayjs(date).format('MMM DD, YYYY'),
|
|
},
|
|
{
|
|
title: 'Actions',
|
|
key: 'actions',
|
|
render: (_: any, record: Application) => (
|
|
<Space>
|
|
<Button
|
|
type="text"
|
|
icon={<EyeOutlined />}
|
|
onClick={() => showDetails(record)}
|
|
title="View Details"
|
|
/>
|
|
<Button
|
|
type="text"
|
|
icon={<EditOutlined />}
|
|
onClick={() => handleEdit(record)}
|
|
title="Edit"
|
|
/>
|
|
<Popconfirm
|
|
title="Are you sure you want to delete this application?"
|
|
onConfirm={() => handleDelete(record.app_id)}
|
|
okText="Yes"
|
|
cancelText="No"
|
|
>
|
|
<Button
|
|
type="text"
|
|
danger
|
|
icon={<DeleteOutlined />}
|
|
title="Delete"
|
|
/>
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<div>
|
|
<Title level={2}>Applications</Title>
|
|
<Text type="secondary">
|
|
Manage your applications and their configurations
|
|
</Text>
|
|
</div>
|
|
<Button
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={handleCreate}
|
|
>
|
|
Create Application
|
|
</Button>
|
|
</div>
|
|
|
|
<Table
|
|
columns={columns}
|
|
dataSource={applications}
|
|
rowKey="app_id"
|
|
loading={loading}
|
|
pagination={{
|
|
pageSize: 10,
|
|
showSizeChanger: true,
|
|
showQuickJumper: true,
|
|
showTotal: (total, range) =>
|
|
`${range[0]}-${range[1]} of ${total} applications`,
|
|
}}
|
|
/>
|
|
</Space>
|
|
|
|
{/* Create/Edit Modal */}
|
|
<Modal
|
|
title={editingApp ? 'Edit Application' : 'Create Application'}
|
|
open={modalVisible}
|
|
onCancel={() => setModalVisible(false)}
|
|
onOk={() => form.submit()}
|
|
width={600}
|
|
>
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
onFinish={handleSubmit}
|
|
>
|
|
<Form.Item
|
|
name="app_id"
|
|
label="Application ID"
|
|
rules={[{ required: true, message: 'Please enter application ID' }]}
|
|
>
|
|
<Input placeholder="com.example.app" disabled={!!editingApp} />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="app_link"
|
|
label="Application Link"
|
|
rules={[
|
|
{ required: true, message: 'Please enter application link' },
|
|
{ type: 'url', message: 'Please enter a valid URL' },
|
|
]}
|
|
>
|
|
<Input placeholder="https://example.com" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="type"
|
|
label="Application Type"
|
|
rules={[{ required: true, message: 'Please select application type' }]}
|
|
>
|
|
<Select mode="multiple" placeholder="Select types">
|
|
<Option value="static">Static</Option>
|
|
<Option value="user">User</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="callback_url"
|
|
label="Callback URL"
|
|
rules={[
|
|
{ required: true, message: 'Please enter callback URL' },
|
|
{ type: 'url', message: 'Please enter a valid URL' },
|
|
]}
|
|
>
|
|
<Input placeholder="https://example.com/callback" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="token_prefix"
|
|
label="Token Prefix"
|
|
rules={[
|
|
{
|
|
pattern: /^[A-Z]{2,4}$/,
|
|
message: 'Token prefix must be 2-4 uppercase letters (e.g., NC for Nerd Completion)'
|
|
},
|
|
]}
|
|
help="Optional custom prefix for tokens. Leave empty for default 'kms_' prefix. Examples: NC → NCT- (static), NCUT- (user)"
|
|
>
|
|
<Input
|
|
placeholder="NC"
|
|
maxLength={4}
|
|
style={{ textTransform: 'uppercase' }}
|
|
onChange={(e) => {
|
|
const value = e.target.value.toUpperCase();
|
|
form.setFieldValue('token_prefix', value);
|
|
}}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item
|
|
name="token_renewal_duration"
|
|
label="Token Renewal Duration"
|
|
rules={[{ required: true, message: 'Please enter duration' }]}
|
|
>
|
|
<Input placeholder="168h" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Form.Item
|
|
name="max_token_duration"
|
|
label="Max Token Duration"
|
|
rules={[{ required: true, message: 'Please enter duration' }]}
|
|
>
|
|
<Input placeholder="720h" />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Form.Item
|
|
name="owner_type"
|
|
label="Owner Type"
|
|
rules={[{ required: true, message: 'Please select owner type' }]}
|
|
>
|
|
<Select placeholder="Select owner type">
|
|
<Option value="individual">Individual</Option>
|
|
<Option value="team">Team</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Form.Item
|
|
name="owner_name"
|
|
label="Owner Name"
|
|
rules={[{ required: true, message: 'Please enter owner name' }]}
|
|
>
|
|
<Input placeholder="John Doe" />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Form.Item
|
|
name="owner_owner"
|
|
label="Owner Contact"
|
|
rules={[{ required: true, message: 'Please enter owner contact' }]}
|
|
>
|
|
<Input placeholder="john.doe@example.com" />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Details Modal */}
|
|
<Modal
|
|
title="Application Details"
|
|
open={detailModalVisible}
|
|
onCancel={() => setDetailModalVisible(false)}
|
|
footer={[
|
|
<Button key="close" onClick={() => setDetailModalVisible(false)}>
|
|
Close
|
|
</Button>,
|
|
]}
|
|
width={700}
|
|
>
|
|
{selectedApp && (
|
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
<Card title="Basic Information">
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Text strong>App ID:</Text>
|
|
<div>
|
|
<Text code>{selectedApp.app_id}</Text>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
onClick={() => copyToClipboard(selectedApp.app_id)}
|
|
/>
|
|
</div>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Text strong>App Link:</Text>
|
|
<div>
|
|
<a href={selectedApp.app_link} target="_blank" rel="noopener noreferrer">
|
|
{selectedApp.app_link}
|
|
</a>
|
|
</div>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16} style={{ marginTop: '16px' }}>
|
|
<Col span={12}>
|
|
<Text strong>Type:</Text>
|
|
<div>
|
|
{selectedApp.type.map((type) => (
|
|
<Tag key={type} color={type === 'static' ? 'blue' : 'green'}>
|
|
{type.toUpperCase()}
|
|
</Tag>
|
|
))}
|
|
</div>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Text strong>Callback URL:</Text>
|
|
<div>{selectedApp.callback_url}</div>
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
|
|
<Card title="Security Configuration">
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Text strong>HMAC Key:</Text>
|
|
<div>
|
|
<Text code>••••••••••••••••</Text>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
icon={<CopyOutlined />}
|
|
onClick={() => copyToClipboard(selectedApp.hmac_key)}
|
|
/>
|
|
</div>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Text strong>Token Prefix:</Text>
|
|
<div>
|
|
{selectedApp.token_prefix ? (
|
|
<>
|
|
<Text code>{selectedApp.token_prefix}</Text>
|
|
<Text type="secondary" style={{ marginLeft: 8 }}>
|
|
(Static: {selectedApp.token_prefix}T-, User: {selectedApp.token_prefix}UT-)
|
|
</Text>
|
|
</>
|
|
) : (
|
|
<Text type="secondary">Default (kms_)</Text>
|
|
)}
|
|
</div>
|
|
</Col>
|
|
</Row>
|
|
<Row gutter={16} style={{ marginTop: '16px' }}>
|
|
<Col span={12}>
|
|
<Text strong>Token Renewal Duration:</Text>
|
|
<div>{formatDuration(selectedApp.token_renewal_duration)}</div>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Text strong>Max Token Duration:</Text>
|
|
<div>{formatDuration(selectedApp.max_token_duration)}</div>
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
|
|
<Card title="Owner Information">
|
|
<Row gutter={16}>
|
|
<Col span={8}>
|
|
<Text strong>Type:</Text>
|
|
<div>
|
|
<Tag color={selectedApp.owner.type === 'individual' ? 'blue' : 'green'}>
|
|
{selectedApp.owner.type.toUpperCase()}
|
|
</Tag>
|
|
</div>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Text strong>Name:</Text>
|
|
<div>{selectedApp.owner.name}</div>
|
|
</Col>
|
|
<Col span={8}>
|
|
<Text strong>Contact:</Text>
|
|
<div>{selectedApp.owner.owner}</div>
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
|
|
<Card title="Timestamps">
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Text strong>Created:</Text>
|
|
<div>{dayjs(selectedApp.created_at).format('MMMM DD, YYYY HH:mm:ss')}</div>
|
|
</Col>
|
|
<Col span={12}>
|
|
<Text strong>Updated:</Text>
|
|
<div>{dayjs(selectedApp.updated_at).format('MMMM DD, YYYY HH:mm:ss')}</div>
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
</Space>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Applications;
|