Skip to content

Commit 5e649ba

Browse files
committed
feat: Certificate management
1 parent c269a44 commit 5e649ba

File tree

12 files changed

+694
-2
lines changed

12 files changed

+694
-2
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Certificate } from '@/integrations/api/instance/certificates/listCertificates';
2+
import { ColumnDef, createColumnHelper } from '@tanstack/react-table';
3+
4+
const columnHelper = createColumnHelper<Certificate>();
5+
6+
export const dataTableColumns: Array<ColumnDef<Certificate>> = [
7+
{
8+
header: 'Certificate Name',
9+
accessorKey: 'name',
10+
enableSorting: true,
11+
},
12+
{
13+
header: 'Issuer',
14+
accessorKey: 'details.issuer',
15+
enableSorting: true,
16+
},
17+
columnHelper.display({
18+
header: 'Expires At',
19+
enableSorting: false,
20+
id: 'details.valid_to',
21+
cell: (props) =>
22+
props.row.original.details.valid_to ? new Date(props.row.original.details.valid_to).toLocaleString() : 'N/A',
23+
}),
24+
];
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { SimpleBrowseDataTable } from '@/components/SimpleBrowseDataTable';
2+
import { Button } from '@/components/ui/button';
3+
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
4+
import { useRefreshClick } from '@/hooks/useRefreshClick';
5+
import { Certificate, listCertificatesQueryOptions } from '@/integrations/api/instance/certificates/listCertificates';
6+
import { useQuery } from '@tanstack/react-query';
7+
import { Link, useNavigate, useParams } from '@tanstack/react-router';
8+
import { Row } from '@tanstack/react-table';
9+
import { PlusIcon, RefreshCwIcon } from 'lucide-react';
10+
import { useCallback, useMemo, useState } from 'react';
11+
import { dataTableColumns } from './constants/tableDefinition';
12+
import { AddCertificateModal } from './modals/AddCertificateModal';
13+
import { ViewCertificateModal } from './modals/ViewCertificateModal';
14+
15+
export function ConfigCertificatesIndex() {
16+
const navigate = useNavigate();
17+
const { certName }: { certName?: string } = useParams({ strict: false });
18+
const instanceParams = useInstanceClientIdParams();
19+
const {
20+
data: certificates,
21+
refetch,
22+
isFetching,
23+
isRefetching,
24+
} = useQuery(listCertificatesQueryOptions(instanceParams));
25+
26+
const selectedCertificate = useMemo(
27+
() => certificates?.find((cert) => cert.name === certName),
28+
[certificates, certName],
29+
);
30+
31+
const onSelectCertificate = useCallback(
32+
(row?: Row<Certificate>) => {
33+
const newCertName = row?.original?.name;
34+
const parts = [certName ? '..' : '', newCertName].filter(Boolean);
35+
void navigate({ to: parts.join('/') });
36+
},
37+
[certName, navigate],
38+
);
39+
40+
const isViewModalOpen = !!certName && !!selectedCertificate;
41+
42+
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
43+
44+
const onAddClicked = useCallback(() => {
45+
setIsAddModalOpen(true);
46+
}, [setIsAddModalOpen]);
47+
48+
const onRefreshClick = useRefreshClick(refetch);
49+
50+
return (
51+
<>
52+
<SimpleBrowseDataTable
53+
columns={dataTableColumns}
54+
data={certificates || []}
55+
isFetching={isFetching}
56+
onRowClick={onSelectCertificate}
57+
>
58+
<Link
59+
className="inline-block underline text-sm text-gray-400 hover:text-white"
60+
to="https://docs.harperdb.io/docs/developers/operations-api/certificate-management"
61+
target="_blank"
62+
>
63+
Certificate Management Docs
64+
</Link>
65+
<Button variant="defaultOutline" onClick={onRefreshClick} accessKey="r" disabled={isFetching || isRefetching}>
66+
<RefreshCwIcon />
67+
<span className="hidden lg:inline-block">
68+
<u>R</u>efresh
69+
</span>
70+
</Button>
71+
<Button variant="positiveOutline" onClick={onAddClicked} accessKey="a" disabled={isAddModalOpen}>
72+
<PlusIcon />
73+
<span>
74+
<u>A</u>dd
75+
</span>
76+
</Button>
77+
</SimpleBrowseDataTable>
78+
{isAddModalOpen && (
79+
<AddCertificateModal
80+
isModalOpen={isAddModalOpen}
81+
onChangesSaved={refetch}
82+
setIsModalOpen={setIsAddModalOpen}
83+
/>
84+
)}
85+
{isViewModalOpen && (
86+
<ViewCertificateModal
87+
isModalOpen={isViewModalOpen}
88+
closeModal={onSelectCertificate}
89+
data={selectedCertificate}
90+
onSelectCertificate={onSelectCertificate}
91+
onChangesSaved={refetch}
92+
/>
93+
)}
94+
</>
95+
);
96+
}
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import { Button } from '@/components/ui/button';
2+
import {
3+
Dialog,
4+
DialogContent,
5+
DialogDescription,
6+
DialogFooter,
7+
DialogHeader,
8+
DialogTitle,
9+
} from '@/components/ui/dialog';
10+
import { Form } from '@/components/ui/form/Form';
11+
import { FormControl } from '@/components/ui/form/FormControl';
12+
import { FormDescription } from '@/components/ui/form/FormDescription';
13+
import { FormField } from '@/components/ui/form/FormField';
14+
import { FormItem } from '@/components/ui/form/FormItem';
15+
import { FormLabel } from '@/components/ui/form/FormLabel';
16+
import { FormMessage } from '@/components/ui/form/FormMessage';
17+
import { Input } from '@/components/ui/input';
18+
import { Switch } from '@/components/ui/switch';
19+
import { Textarea } from '@/components/ui/textarea';
20+
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
21+
import { CertificateSchema, useAddCertificate } from '@/integrations/api/instance/certificates/useAddCertificate';
22+
import { zodResolver } from '@hookform/resolvers/zod';
23+
import { Save } from 'lucide-react';
24+
import { useCallback } from 'react';
25+
import { useForm } from 'react-hook-form';
26+
import { toast } from 'sonner';
27+
import { z } from 'zod';
28+
29+
export function AddCertificateModal({
30+
isModalOpen,
31+
onChangesSaved,
32+
setIsModalOpen,
33+
}: {
34+
isModalOpen: boolean;
35+
onChangesSaved: () => void;
36+
setIsModalOpen: (open: boolean) => void;
37+
}) {
38+
const form = useForm({
39+
resolver: zodResolver(CertificateSchema),
40+
defaultValues: {
41+
name: '',
42+
certificate: '',
43+
private_key: '',
44+
is_authority: false,
45+
hosts: '',
46+
uses: '',
47+
},
48+
});
49+
const instanceParams = useInstanceClientIdParams();
50+
const { mutate: addCertificate, isPending } = useAddCertificate();
51+
52+
const onSubmitClick = useCallback(
53+
async (formData: z.infer<typeof CertificateSchema>) => {
54+
const { hosts, uses, private_key, ...rest } = formData;
55+
const hostsArray = hosts
56+
? hosts
57+
.split(',')
58+
.map((h) => h.trim())
59+
.filter(Boolean)
60+
: undefined;
61+
62+
const usesArray = uses
63+
? uses
64+
.split(',')
65+
.map((u) => u.trim())
66+
.filter(Boolean)
67+
: undefined;
68+
69+
addCertificate(
70+
{
71+
instanceParams,
72+
certificateParams: {
73+
...rest,
74+
private_key: private_key || undefined,
75+
hosts: hostsArray && hostsArray.length > 0 ? hostsArray : undefined,
76+
uses: usesArray && usesArray.length > 0 ? usesArray : undefined,
77+
},
78+
},
79+
{
80+
onSuccess: () => {
81+
form.reset();
82+
onChangesSaved();
83+
toast.success('Certificate added successfully!');
84+
setIsModalOpen(false);
85+
},
86+
},
87+
);
88+
},
89+
[addCertificate, form, instanceParams, onChangesSaved, setIsModalOpen],
90+
);
91+
92+
const onClickCancel = useCallback(() => {
93+
form.reset();
94+
setIsModalOpen(false);
95+
}, [form, setIsModalOpen]);
96+
97+
return (
98+
<Dialog onOpenChange={setIsModalOpen} open={isModalOpen}>
99+
<DialogContent aria-describedby={undefined}>
100+
<Form {...form}>
101+
<form onSubmit={form.handleSubmit(onSubmitClick)} className="grid gap-4 my-4">
102+
<DialogHeader>
103+
<DialogTitle>Add New Certificate</DialogTitle>
104+
<DialogDescription>
105+
Enter the details of your Certificate below.
106+
</DialogDescription>
107+
</DialogHeader>
108+
109+
<FormField
110+
control={form.control}
111+
name="name"
112+
render={({ field }) => (
113+
<FormItem>
114+
<FormLabel className="pb-1">Name</FormLabel>
115+
<FormControl>
116+
<Input
117+
type="text"
118+
autoComplete="off"
119+
autoCapitalize="off"
120+
autoFocus={true}
121+
{...field}
122+
/>
123+
</FormControl>
124+
<FormMessage />
125+
</FormItem>
126+
)}
127+
/>
128+
129+
<FormField
130+
control={form.control}
131+
name="is_authority"
132+
render={({ field }) => (
133+
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-gray-800 p-3 shadow-sm">
134+
<div className="space-y-0.5">
135+
<FormLabel>Is Authority</FormLabel>
136+
</div>
137+
<FormControl>
138+
<Switch
139+
checked={field.value}
140+
onCheckedChange={field.onChange}
141+
/>
142+
</FormControl>
143+
</FormItem>
144+
)}
145+
/>
146+
147+
<FormField
148+
control={form.control}
149+
name="certificate"
150+
render={({ field }) => (
151+
<FormItem>
152+
<FormLabel className="pb-1">Certificate</FormLabel>
153+
<FormControl>
154+
<Textarea
155+
autoComplete="off"
156+
autoCapitalize="off"
157+
rows={5}
158+
{...field}
159+
/>
160+
</FormControl>
161+
<FormMessage />
162+
</FormItem>
163+
)}
164+
/>
165+
166+
<FormField
167+
control={form.control}
168+
name="hosts"
169+
render={({ field }) => (
170+
<FormItem>
171+
<FormLabel className="pb-1">Hosts (optional)</FormLabel>
172+
<FormDescription>
173+
Comma-separated list of hostnames this certificate is valid for.
174+
</FormDescription>
175+
<FormControl>
176+
<Input
177+
type="text"
178+
autoComplete="off"
179+
autoCapitalize="off"
180+
placeholder="e.g. example.com, *.example.com"
181+
{...field}
182+
/>
183+
</FormControl>
184+
<FormMessage />
185+
</FormItem>
186+
)}
187+
/>
188+
189+
<FormField
190+
control={form.control}
191+
name="uses"
192+
render={({ field }) => (
193+
<FormItem>
194+
<FormLabel className="pb-1">Uses (optional)</FormLabel>
195+
<FormDescription>
196+
Comma-separated list of intended uses, e.g. "https, operations, wss".
197+
</FormDescription>
198+
<FormControl>
199+
<Input
200+
type="text"
201+
autoComplete="off"
202+
autoCapitalize="off"
203+
placeholder="e.g. https, operations, wss"
204+
{...field}
205+
/>
206+
</FormControl>
207+
<FormMessage />
208+
</FormItem>
209+
)}
210+
/>
211+
212+
<FormField
213+
control={form.control}
214+
name="private_key"
215+
render={({ field }) => (
216+
<FormItem>
217+
<FormLabel className="pb-1">Private Key (optional)</FormLabel>
218+
<FormDescription>
219+
PEM formatted private key string.
220+
</FormDescription>
221+
<FormControl>
222+
<Textarea
223+
autoComplete="off"
224+
autoCapitalize="off"
225+
rows={5}
226+
{...field}
227+
/>
228+
</FormControl>
229+
<FormMessage />
230+
</FormItem>
231+
)}
232+
/>
233+
234+
<DialogFooter>
235+
<div className="flex justify-between w-full">
236+
<Button
237+
variant="destructiveOutline"
238+
type="button"
239+
className="rounded-full"
240+
onClick={onClickCancel}
241+
disabled={isPending}
242+
>
243+
Cancel
244+
</Button>
245+
<Button
246+
type="submit"
247+
variant="submit"
248+
className="rounded-full"
249+
disabled={isPending || !form.formState.isDirty || !form.formState.isValid}
250+
>
251+
<Save /> Add Certificate
252+
</Button>
253+
</div>
254+
</DialogFooter>
255+
</form>
256+
</Form>
257+
</DialogContent>
258+
</Dialog>
259+
);
260+
}

0 commit comments

Comments
 (0)