feat: create certificate page

This commit is contained in:
Home
2025-10-18 18:53:38 +03:00
parent f572b28711
commit 743ca72965
16 changed files with 440 additions and 46 deletions

View File

@@ -0,0 +1,76 @@
import type { FC } from 'react';
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
} from '@heroui/react';
import type { CertificateDTO } from '../../duck';
export interface CertificateDetailProps {
cetificate: CertificateDTO;
isOpen: boolean;
onOpenChange: () => void;
}
export const CertificateDetail: FC<CertificateDetailProps> = (props) => {
const { cetificate, isOpen, onOpenChange } = props;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
Modal Title
</ModalHeader>
<ModalBody>
<div>
<h4>Basic Information</h4>
<p>
<strong>Name:</strong> ${cetificate.name}
</p>
<p>
<strong>Domain:</strong> ${cetificate.domain}
</p>
<p>
<strong>Type:</strong> ${cetificate.cert_type}
</p>
<p>
<strong>Auto Renew:</strong>
{cetificate.auto_renew ? 'Yes' : 'No'}
</p>
<p>
<strong>Created:</strong>
{new Date(cetificate.created_at).toLocaleString()}
</p>
<p>
<strong>Expires:</strong>
{new Date(cetificate.expires_at).toLocaleString()}
</p>
<h4>Certificate PEM</h4>
<div className="cert-details">
{cetificate.certificate_pem || 'Not available'}
</div>
<h4>Private Key</h4>
<div className="cert-details">
{cetificate.has_private_key
? '[Hidden for security]'
: 'Not available'}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,93 @@
import { useEffect, type FC } from 'react';
import { useForm } from 'react-hook-form';
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
Button,
} from '@heroui/react';
import { useAppDispatch } from '../../../../common/hooks';
import { getCertificate } from '../../duck/api';
import { updateCertificate, type EditCertificateDTO } from '../../duck';
export interface CertificateEditProps {
certificateId: string;
isOpen: boolean;
onOpenChange: () => void;
}
export const CertificateEdit: FC<CertificateEditProps> = (props) => {
const dispatch = useAppDispatch();
const { certificateId, isOpen, onOpenChange } = props;
const { register, handleSubmit, reset } = useForm<EditCertificateDTO>();
useEffect(() => {
getCertificate(certificateId).then((response) => {
const { data } = response;
reset({
...data,
});
});
}, [certificateId]);
const onSubmit = (values: EditCertificateDTO) => {
dispatch(
updateCertificate({
id: certificateId,
certificate: values
}),
).then(() => {
onOpenChange();
});
};
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<form onSubmit={handleSubmit(onSubmit)}>
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
Modal Title
</ModalHeader>
<ModalBody>
<div>
<div className="form-group">
<label>Name:</label>
<input
type="text"
{...register('name', { required: true })}
/>
</div>
<div className="form-group">
<label>Domain:</label>
<input
type="text"
{...register('domain', { required: true })}
/>
</div>
<div className="form-group">
<label>
<input type="checkbox" {...register('auto_renew')} /> Auto
Renew
</label>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button color="primary" type="submit">
Save
</Button>
<Button color="danger" variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</form>
</Modal>
);
};

View File

@@ -0,0 +1,24 @@
import type { FC } from 'react';
import type { CertificateDTO } from '../../duck';
import { CertificateView } from './certificate-view';
export interface CertificateList {
certificates: CertificateDTO[];
}
export const CertificateList: FC<CertificateList> = ({ certificates }) => {
return (
<table>
<tr>
<th>Name</th>
<th>Domain</th>
<th>Type</th>
<th>Expires</th>
<th>Auto Renew</th>
<th>Actions</th>
</tr>
{certificates
.map((certificate)=><CertificateView certificate={certificate} key={certificate.id}/>)}
</table>
);
};

View File

@@ -0,0 +1,56 @@
import type { FC } from 'react';
import { deleteCertificateAction, type CertificateDTO } from '../../duck';
import { useDisclosure } from '@heroui/react';
import { CertificateDetail } from './certificate-details';
import { CertificateEdit } from './certificate-edit';
import { useAppDispatch } from '../../../../common/hooks';
export interface CertificateViewProps {
certificate: CertificateDTO;
}
export const CertificateView: FC<CertificateViewProps> = ({ certificate }) => {
const dispatch = useAppDispatch()
const detailDisclosure = useDisclosure();
const editDisclosure = useDisclosure();
const handleDeleteCertificate = () => {
if (confirm('Delete certificate?')) {
dispatch(deleteCertificateAction(certificate.id));
}
};
return (
<>
<tr>
<td>{certificate.name}</td>
<td>{certificate.domain}</td>
<td>{certificate.cert_type}</td>
<td>{new Date(certificate.expires_at).toLocaleDateString()}</td>
<td>{certificate.auto_renew ? 'Yes' : 'No'}</td>
<td>
<button
className="btn btn-secondary"
onClick={detailDisclosure.onOpenChange}
>
View
</button>
<button
className="btn btn-primary"
onClick={editDisclosure.onOpenChange}
>
Edit
</button>
<button
className="btn btn-danger"
onClick={handleDeleteCertificate}
>
Delete
</button>
</td>
</tr>
<CertificateDetail cetificate={certificate} {...detailDisclosure} />
<CertificateEdit {...editDisclosure} certificateId={certificate.id} />
</>
);
};

View File

@@ -0,0 +1 @@
export * from './certificate-list'

View File

@@ -0,0 +1,51 @@
import type { FC } from 'react';
import { useForm } from 'react-hook-form';
import { createCertificateAction, type CreateCertificateDTO } from '../../duck';
import { useAppDispatch } from '../../../../common/hooks';
export const CreateCertificate: FC = () => {
const { handleSubmit, register, reset } = useForm<CreateCertificateDTO>();
const dispatch = useAppDispatch();
const onSubmit = (values: CreateCertificateDTO) => {
dispatch(createCertificateAction(values)).then(() => {
reset();
});
};
return (
<form id="certificateForm" onSubmit={handleSubmit(onSubmit)}>
<div className="form-group">
<label>Name:</label>
<input type="text" {...register('name', { required: true })} />
</div>
<div className="form-group">
<label>Domain:</label>
<input
type="text"
placeholder="example.com"
{...register('domain', { required: true })}
/>
</div>
<div className="form-group">
<label>Certificate Type:</label>
<select id="certType" {...register('cert_type', { required: true })}>
<option value="self_signed">Self-Signed</option>
</select>
</div>
<div className="form-group">
<label>
<input
type="checkbox"
id="certAutoRenew"
{...register('auto_renew')}
/>{' '}
Auto Renew
</label>
</div>
<button type="submit" className="btn btn-primary">
Generate Certificate
</button>
</form>
);
};

View File

@@ -0,0 +1 @@
export * from './create-certificate'

View File

@@ -0,0 +1 @@
export * from './create-certificate'

View File

@@ -1,10 +1,12 @@
import { certificateSlice } from './slice'; import { certificateSlice } from './slice';
import { getCertificates } from './api'; import { createCertificate, deleteCertificate, getCertificates, patchCertificate } from './api';
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { getCertificatesState } from './selectors'; import { getCertificatesState } from './selectors';
import type { RootState } from '../../../store'; import type { RootState } from '../../../store';
import { appNotificator } from '../../../utils/notification/app-notificator';
import type { CreateCertificateDTO, EditCertificateDTO } from './dto';
const PREFFIX = 'certificates' const PREFFIX = 'certificates';
export const fetchCertificates = createAsyncThunk( export const fetchCertificates = createAsyncThunk(
`${PREFFIX}/fetchAll`, `${PREFFIX}/fetchAll`,
@@ -18,7 +20,6 @@ export const fetchCertificates = createAsyncThunk(
dispatch(certificateSlice.actions.setLoading(true)); dispatch(certificateSlice.actions.setLoading(true));
const response = await getCertificates().then(({ data }) => data); const response = await getCertificates().then(({ data }) => data);
dispatch(certificateSlice.actions.setUsers(response)); dispatch(certificateSlice.actions.setUsers(response));
} catch (e) { } catch (e) {
const message = const message =
e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`; e instanceof Error ? e.message : `Unknown error in ${PREFFIX}/fetchAll`;
@@ -28,3 +29,71 @@ export const fetchCertificates = createAsyncThunk(
} }
}, },
); );
export const createCertificateAction = createAsyncThunk(
`${PREFFIX}/createCertificates`,
async (params: CreateCertificateDTO, { dispatch }) => {
try {
await createCertificate(params);
dispatch(fetchCertificates());
} catch (e) {
appNotificator.add({
message:
e instanceof Error
? e.message
: `Unknown error in ${PREFFIX}/createCertificates`,
type: 'error',
});
}
},
);
export const updateCertificate = createAsyncThunk(
`${PREFFIX}/updateCertificate`,
async (
params: {
id: string;
certificate: EditCertificateDTO;
},
{ dispatch },
) => {
try {
await patchCertificate(params.id, params.certificate);
dispatch(fetchCertificates());
appNotificator.add({
message: 'Template updated',
type: 'success',
});
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Error updating: ${e.message}`
: `Unknown error in ${PREFFIX}/updateTemplate`,
});
}
},
);
export const deleteCertificateAction = createAsyncThunk(
`${PREFFIX}/deleteCertificate`,
async (id: string, { dispatch }) => {
try {
await deleteCertificate(id);
appNotificator.add({
message: 'Certificate deleted',
type: 'success',
});
dispatch(fetchCertificates());
} catch (e) {
appNotificator.add({
type: 'error',
message:
e instanceof Error
? `Delete error: ${e.message}`
: `Unknown error in ${PREFFIX}/deleteCertificate`,
});
}
},
);

View File

@@ -1,6 +1,20 @@
import type { AxiosResponse } from 'axios'; import type { AxiosResponse } from 'axios';
import { api } from '../../../api/api'; import { api } from '../../../api/api';
import type { CertificateDTO } from './dto'; import type { CertificateDTO, CreateCertificateDTO, EditCertificateDTO } from './dto';
export const getCertificates = () => export const getCertificates = () =>
api.get<never, AxiosResponse<CertificateDTO[]>>('/certificates'); api.get<never, AxiosResponse<CertificateDTO[]>>('/certificates');
export const createCertificate = (params: CreateCertificateDTO) =>
api.post<AxiosResponse>('/certificates', params, {
headers: { 'Content-Type': 'application/json' },
});
export const getCertificate = (id: string) => api.get<never, AxiosResponse<CertificateDTO>>(`/certificates/${id}`)
export const patchCertificate = (id: string, certificate: EditCertificateDTO) =>
api.put(`/certificates/${id}`, certificate, {
headers: { 'Content-Type': 'application/json' },
});
export const deleteCertificate = (id: string) => api.delete(`/certificates/${id}`);

View File

@@ -1 +1,24 @@
export interface CertificateDTO {} export interface CertificateDTO {
name: string;
domain: string;
cert_type: string;
expires_at: string;
auto_renew: boolean;
id: string
created_at: string
certificate_pem: string
has_private_key: boolean
}
export interface CreateCertificateDTO {
name: string;
domain: string;
cert_type: string;
auto_renew: boolean;
}
export interface EditCertificateDTO {
name: string
domain: string
auto_renew: boolean
}

View File

@@ -1 +1,2 @@
export * from './duck' export * from './duck'
export * from './components'

View File

@@ -14,13 +14,13 @@ import { getTemplateById } from '../../duck/api';
import { protocolOptions } from '../add-template/util'; import { protocolOptions } from '../add-template/util';
import { updateTemplate } from '../../duck'; import { updateTemplate } from '../../duck';
export interface ServerEditProps { export interface TemplateEditProps {
templateId: string; templateId: string;
isOpen: boolean; isOpen: boolean;
onOpenChange: () => void; onOpenChange: () => void;
} }
export const TemplateEdit: FC<ServerEditProps> = (props) => { export const TemplateEdit: FC<TemplateEditProps> = (props) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { templateId, isOpen, onOpenChange } = props; const { templateId, isOpen, onOpenChange } = props;
const { register, handleSubmit, reset } = useForm<EditTemplateForm>(); const { register, handleSubmit, reset } = useForm<EditTemplateForm>();
@@ -41,12 +41,10 @@ export const TemplateEdit: FC<ServerEditProps> = (props) => {
default_port: parseInt(values.default_port), default_port: parseInt(values.default_port),
}; };
console.log({data})
dispatch( dispatch(
updateTemplate({ updateTemplate({
id: templateId, id: templateId,
server: data, template: data,
}), }),
).then(() => { ).then(() => {
onOpenChange(); onOpenChange();

View File

@@ -56,12 +56,12 @@ export const updateTemplate = createAsyncThunk(
async ( async (
params: { params: {
id: string; id: string;
server: EditTemplateDTO; template: EditTemplateDTO;
}, },
{ dispatch }, { dispatch },
) => { ) => {
try { try {
await patchTemplate(params.id, params.server); await patchTemplate(params.id, params.template);
dispatch(fetchTemplates()); dispatch(fetchTemplates());
appNotificator.add({ appNotificator.add({
message: 'Template updated', message: 'Template updated',
@@ -85,7 +85,7 @@ export const deleteTemplateAction = createAsyncThunk(
try { try {
await deleteTemplate(id); await deleteTemplate(id);
appNotificator.add({ appNotificator.add({
message: 'Server deleted', message: 'Template deleted',
type: 'success', type: 'success',
}); });
dispatch(fetchTemplates()); dispatch(fetchTemplates());

View File

@@ -15,8 +15,8 @@ export const createTemplate = (params: CreateTemplateDTO) =>
export const getTemplateById = (id: string) => export const getTemplateById = (id: string) =>
api.get<string, AxiosResponse<TemplateDTO>>(`/templates/${id}`); api.get<string, AxiosResponse<TemplateDTO>>(`/templates/${id}`);
export const patchTemplate = (id: string, server: EditTemplateDTO) => export const patchTemplate = (id: string, template: EditTemplateDTO) =>
api.put(`/templates/${id}`, server, { api.put(`/templates/${id}`, template, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });

View File

@@ -1,45 +1,31 @@
import type { RouteObject } from 'react-router'; import type { RouteObject } from 'react-router';
import { useAppDispatch, useAppSelector } from '../../common/hooks';
import { useEffect } from 'react';
import { fetchCertificates, getCertificatesState } from '../../features';
import { CreateCertificate } from '../../features/certificates';
import { CertificateList } from '../../features/certificates/components/certificate-list';
export const Certificates = () => { export const Certificates = () => {
const dispatch = useAppDispatch()
const { loading, certificates } = useAppSelector(getCertificatesState)
useEffect(()=>{
dispatch(fetchCertificates())
}, [dispatch])
return ( return (
<div id="certificates" className="tab-content active"> <div id="certificates" className="tab-content active">
<div className="section"> <div className="section">
<h2>Add Certificate</h2> <h2>Add Certificate</h2>
<form id="certificateForm"> <CreateCertificate/>
<div className="form-group">
<label>Name:</label>
<input type="text" id="certName" required />
</div>
<div className="form-group">
<label>Domain:</label>
<input
type="text"
id="certDomain"
placeholder="example.com"
required
/>
</div>
<div className="form-group">
<label>Certificate Type:</label>
<select id="certType" required>
<option value="self_signed">Self-Signed</option>
</select>
</div>
<div className="form-group">
<label>
<input type="checkbox" id="certAutoRenew" checked /> Auto Renew
</label>
</div>
<button type="submit" className="btn btn-primary">
Generate Certificate
</button>
</form>
</div> </div>
<div className="section"> <div className="section">
<h2>Certificates List</h2> <h2>Certificates List</h2>
<div id="certificatesList" className="loading"> <div id="certificatesList" className="loading">
Loading... { loading && 'Loading...' }
{ certificates.length ? <CertificateList certificates={certificates}/> : <p>No certificates found</p> }
</div> </div>
</div> </div>
</div> </div>