feat: 添加TLS证书详情和编辑功能,更新相关API和前端组件

This commit is contained in:
ryan
2026-03-13 13:45:29 +08:00
parent 55d1a3f2c8
commit a0f920cf05
11 changed files with 606 additions and 8 deletions
+79
View File
@@ -31,6 +31,40 @@ func GetTLSCertificates(c *gin.Context) {
})
}
// GetTLSCertificate godoc
// @Summary Get TLS certificate detail
// @Tags TLSCertificates
// @Produce json
// @Security BearerAuth
// @Param id path int true "Certificate ID"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/{id} [get]
func GetTLSCertificate(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "invalid request",
})
return
}
certificate, err := service.GetTLSCertificate(uint(id))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": certificate,
})
}
// CreateTLSCertificate godoc
// @Summary Create TLS certificate from PEM
// @Tags TLSCertificates
@@ -65,6 +99,51 @@ func CreateTLSCertificate(c *gin.Context) {
})
}
// UpdateTLSCertificate godoc
// @Summary Update TLS certificate from PEM
// @Tags TLSCertificates
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Certificate ID"
// @Param payload body service.TLSCertificateInput true "TLS certificate payload"
// @Success 200 {object} map[string]interface{}
// @Failure 400 {object} map[string]interface{}
// @Router /api/tls-certificates/{id} [put]
func UpdateTLSCertificate(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "invalid request",
})
return
}
var input service.TLSCertificateInput
if err = json.NewDecoder(c.Request.Body).Decode(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "invalid request",
})
return
}
certificate, err := service.UpdateTLSCertificate(uint(id), input)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": certificate,
})
}
// ImportTLSCertificateFile godoc
// @Summary Import TLS certificate from files
// @Tags TLSCertificates
+4
View File
@@ -29,6 +29,10 @@ func (certificate *TLSCertificate) Insert() error {
return DB.Create(certificate).Error
}
func (certificate *TLSCertificate) Update() error {
return DB.Save(certificate).Error
}
func (certificate *TLSCertificate) Delete() error {
return DB.Delete(certificate).Error
}
+2
View File
@@ -92,7 +92,9 @@ func SetApiRouter(router *gin.Engine) {
tlsCertificateRoute.Use(middleware.AdminAuth())
{
tlsCertificateRoute.GET("/", controller.GetTLSCertificates)
tlsCertificateRoute.GET("/:id", controller.GetTLSCertificate)
tlsCertificateRoute.POST("/", controller.CreateTLSCertificate)
tlsCertificateRoute.PUT("/:id", controller.UpdateTLSCertificate)
tlsCertificateRoute.POST("/import-file", controller.ImportTLSCertificateFile)
tlsCertificateRoute.DELETE("/:id", controller.DeleteTLSCertificate)
}
+19
View File
@@ -184,6 +184,25 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
t.Fatal("expected manual certificate import to persist certificate")
}
detailResp := performJSONRequest(t, engine, token, http.MethodGet, "/api/tls-certificates/"+toString(manualCertificate.ID), nil)
var certificateDetail model.TLSCertificate
decodeResponseData(t, detailResp, &certificateDetail)
if certificateDetail.ID != manualCertificate.ID || certificateDetail.CertPEM == "" || certificateDetail.KeyPEM == "" {
t.Fatal("expected certificate detail endpoint to return pem payloads")
}
updatedCertPEM, updatedKeyPEM := generateCertificatePairForRouterTest(t, []string{"secure.example.com", "www.secure.example.com"})
updateCertificateResp := performJSONRequest(t, engine, token, http.MethodPut, "/api/tls-certificates/"+toString(manualCertificate.ID), map[string]any{
"name": "secure-example-updated",
"cert_pem": updatedCertPEM,
"key_pem": updatedKeyPEM,
"remark": "updated manual import",
})
decodeResponseData(t, updateCertificateResp, &manualCertificate)
if manualCertificate.Name != "secure-example-updated" || manualCertificate.Remark != "updated manual import" {
t.Fatalf("expected certificate update to persist metadata, got %+v", manualCertificate)
}
fileCertPEM, fileKeyPEM := generateCertificatePairForRouterTest(t, []string{"upload.example.com"})
multipartResp := performMultipartRequest(t, engine, token, "/api/tls-certificates/import-file", map[string]string{
"name": "upload-example",
+23
View File
@@ -20,6 +20,10 @@ func ListTLSCertificates() ([]*model.TLSCertificate, error) {
return model.ListTLSCertificates()
}
func GetTLSCertificate(id uint) (*model.TLSCertificate, error) {
return model.GetTLSCertificateByID(id)
}
func CreateTLSCertificate(input TLSCertificateInput) (*model.TLSCertificate, error) {
certificate, err := buildTLSCertificate(nil, input)
if err != nil {
@@ -54,6 +58,25 @@ func CreateTLSCertificateFromFiles(name string, certFile *multipart.FileHeader,
})
}
func UpdateTLSCertificate(id uint, input TLSCertificateInput) (*model.TLSCertificate, error) {
existing, err := model.GetTLSCertificateByID(id)
if err != nil {
return nil, err
}
certificate, err := buildTLSCertificate(existing, input)
if err != nil {
return nil, err
}
if err = certificate.Update(); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New("certificate name already exists")
}
return nil, err
}
return certificate, nil
}
func DeleteTLSCertificate(id uint) error {
var routeCount int64
if err := model.DB.Model(&model.ProxyRoute{}).Where("cert_id = ?", id).Count(&routeCount).Error; err != nil {
@@ -1,6 +1,7 @@
import { apiRequest } from '@/lib/api/client';
import type {
TlsCertificateDetailItem,
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
@@ -17,6 +18,20 @@ export function createTlsCertificate(payload: TlsCertificateMutationPayload) {
});
}
export function getTlsCertificate(id: number) {
return apiRequest<TlsCertificateDetailItem>(`/tls-certificates/${id}`);
}
export function updateTlsCertificate(
id: number,
payload: TlsCertificateMutationPayload,
) {
return apiRequest<TlsCertificateItem>(`/tls-certificates/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export function importTlsCertificateFiles(payload: TlsCertificateFileImportPayload) {
const formData = new FormData();
formData.append('name', payload.name);
@@ -1,6 +1,8 @@
export interface TlsCertificateItem {
id: number;
name: string;
cert_pem?: string;
key_pem?: string;
not_before: string;
not_after: string;
remark: string;
@@ -8,6 +10,11 @@ export interface TlsCertificateItem {
updated_at: string;
}
export interface TlsCertificateDetailItem extends TlsCertificateItem {
cert_pem: string;
key_pem: string;
}
export interface TlsCertificateMutationPayload {
name: string;
cert_pem: string;
@@ -0,0 +1,157 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import { StatusBadge } from '@/components/ui/status-badge';
import { getTlsCertificate } from '@/features/tls-certificates/api/tls-certificates';
import { getCertificateStatus, getErrorMessage } from '@/features/websites/utils';
import {
CodeBlock,
DangerButton,
PrimaryButton,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
interface CertificateDetailModalProps {
certificateId: number | null;
isOpen: boolean;
onClose: () => void;
onEdit: () => void;
onDelete: () => void;
deleting?: boolean;
}
export function CertificateDetailModal({
certificateId,
isOpen,
onClose,
onEdit,
onDelete,
deleting = false,
}: CertificateDetailModalProps) {
const certificateQuery = useQuery({
queryKey: ['tls-certificates', 'detail', certificateId],
queryFn: () => getTlsCertificate(certificateId as number),
enabled: isOpen && certificateId !== null,
});
const certificate = certificateQuery.data;
const status = certificate ? getCertificateStatus(certificate) : null;
return (
<AppModal
isOpen={isOpen}
onClose={onClose}
title="证书详情"
description="查看证书元信息、备注以及当前保存的 PEM 内容。"
size="xl"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton type="button" onClick={onClose}>
关闭
</SecondaryButton>
<PrimaryButton
type="button"
onClick={onEdit}
disabled={!certificate}
>
编辑证书
</PrimaryButton>
<DangerButton
type="button"
onClick={onDelete}
disabled={!certificate || deleting}
>
{deleting ? '删除中...' : '删除证书'}
</DangerButton>
</div>
}
>
{certificateQuery.isLoading ? (
<LoadingState />
) : certificateQuery.isError ? (
<ErrorState
title="证书详情加载失败"
description={getErrorMessage(certificateQuery.error)}
/>
) : !certificate ? (
<EmptyState
title="证书不存在"
description="当前证书可能已被删除。"
/>
) : (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
证书名称
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{certificate.name}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
状态
</p>
<div className="mt-2">
{status ? (
<StatusBadge label={status.label} variant={status.variant} />
) : null}
</div>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
生效时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(certificate.not_before)}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
到期时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(certificate.not_after)}
</p>
</div>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs uppercase tracking-[0.18em] text-[var(--foreground-muted)]">
备注
</p>
<p className="mt-2 text-sm leading-6 text-[var(--foreground-primary)]">
{certificate.remark || '暂无备注'}
</p>
</div>
<div className="space-y-4">
<div>
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
证书 PEM
</p>
<CodeBlock className="max-h-56 overflow-y-auto whitespace-pre-wrap break-all">
{certificate.cert_pem}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
私钥 PEM
</p>
<CodeBlock className="max-h-56 overflow-y-auto whitespace-pre-wrap break-all">
{certificate.key_pem}
</CodeBlock>
</div>
</div>
</div>
)}
</AppModal>
);
}
@@ -0,0 +1,182 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { AppModal } from '@/components/ui/app-modal';
import {
getTlsCertificate,
updateTlsCertificate,
} from '@/features/tls-certificates/api/tls-certificates';
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import {
defaultManualImportValues,
manualImportSchema,
type ManualImportFormValues,
} from '@/features/websites/schemas';
import { getErrorMessage, toManualPayload } from '@/features/websites/utils';
import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceTextarea,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
interface CertificateEditorModalProps {
certificateId: number | null;
isOpen: boolean;
onClose: () => void;
onSaved?: (certificate: TlsCertificateItem) => void;
}
export function CertificateEditorModal({
certificateId,
isOpen,
onClose,
onSaved,
}: CertificateEditorModalProps) {
const queryClient = useQueryClient();
const form = useForm<ManualImportFormValues>({
resolver: zodResolver(manualImportSchema),
defaultValues: defaultManualImportValues,
});
const certificateQuery = useQuery({
queryKey: ['tls-certificates', 'detail', certificateId],
queryFn: () => getTlsCertificate(certificateId as number),
enabled: isOpen && certificateId !== null,
});
const updateMutation = useMutation({
mutationFn: async (values: ManualImportFormValues) =>
updateTlsCertificate(certificateId as number, toManualPayload(values)),
onSuccess: async (certificate) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['tls-certificates'] }),
queryClient.invalidateQueries({ queryKey: ['managed-domains'] }),
]);
onSaved?.(certificate);
handleClose();
},
});
useEffect(() => {
if (!isOpen || !certificateQuery.data) {
return;
}
form.reset({
name: certificateQuery.data.name,
cert_pem: certificateQuery.data.cert_pem,
key_pem: certificateQuery.data.key_pem,
remark: certificateQuery.data.remark || '',
});
}, [certificateQuery.data, form, isOpen]);
const handleSubmit = form.handleSubmit((values) => {
updateMutation.mutate(values);
});
const handleClose = () => {
updateMutation.reset();
form.reset(defaultManualImportValues);
onClose();
};
return (
<AppModal
isOpen={isOpen}
onClose={handleClose}
title="编辑证书"
description="可以修改证书名称、备注,以及重新上传 PEM 证书和私钥内容。"
size="xl"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={handleClose}
disabled={updateMutation.isPending}
>
取消
</SecondaryButton>
<PrimaryButton
type="submit"
form="certificate-editor-form"
disabled={updateMutation.isPending || certificateQuery.isLoading}
>
{updateMutation.isPending ? '保存中...' : '保存证书'}
</PrimaryButton>
</div>
}
>
{certificateQuery.isLoading ? (
<LoadingState />
) : certificateQuery.isError ? (
<ErrorState
title="证书详情加载失败"
description={getErrorMessage(certificateQuery.error)}
/>
) : !certificateQuery.data ? (
<EmptyState
title="证书不存在"
description="当前证书可能已被删除。"
/>
) : (
<form
id="certificate-editor-form"
className="space-y-5"
onSubmit={handleSubmit}
>
{updateMutation.isError ? (
<InlineMessage
tone="danger"
message={getErrorMessage(updateMutation.error)}
/>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<ResourceField
label="证书名称"
error={form.formState.errors.name?.message}
>
<ResourceInput {...form.register('name')} />
</ResourceField>
<ResourceField
label="备注"
error={form.formState.errors.remark?.message}
>
<ResourceInput {...form.register('remark')} />
</ResourceField>
</div>
<ResourceField
label="证书 PEM"
error={form.formState.errors.cert_pem?.message}
>
<ResourceTextarea
className="min-h-40 font-mono text-xs"
{...form.register('cert_pem')}
/>
</ResourceField>
<ResourceField
label="私钥 PEM"
error={form.formState.errors.key_pem?.message}
>
<ResourceTextarea
className="min-h-40 font-mono text-xs"
{...form.register('key_pem')}
/>
</ResourceField>
</form>
)}
</AppModal>
);
}
@@ -23,6 +23,8 @@ import {
deleteTlsCertificate,
getTlsCertificates,
} from '@/features/tls-certificates/api/tls-certificates';
import { CertificateDetailModal } from '@/features/websites/components/certificate-detail-modal';
import { CertificateEditorModal } from '@/features/websites/components/certificate-editor-modal';
import { CertificateImportModal } from '@/features/websites/components/certificate-import-modal';
import { WebsiteEditorModal } from '@/features/websites/components/website-editor-modal';
import {
@@ -50,6 +52,8 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [isCertificateImportOpen, setIsCertificateImportOpen] = useState(false);
const [isCertificateDetailOpen, setIsCertificateDetailOpen] = useState(false);
const [isCertificateEditorOpen, setIsCertificateEditorOpen] = useState(false);
const [preferredCertificateId, setPreferredCertificateId] = useState<
number | null
>(null);
@@ -339,6 +343,20 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
<p>到期时间:{formatDateTime(certificate.not_after)}</p>
<p>备注:{certificate.remark || '暂无备注'}</p>
</div>
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() => setIsCertificateDetailOpen(true)}
>
查看证书
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() => setIsCertificateEditorOpen(true)}
>
编辑证书
</SecondaryButton>
</div>
<DangerButton
type="button"
onClick={handleDeleteCertificate}
@@ -470,6 +488,33 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
setIsEditorOpen(true);
}}
/>
<CertificateDetailModal
certificateId={certificate?.id ?? null}
isOpen={isCertificateDetailOpen}
onClose={() => setIsCertificateDetailOpen(false)}
onEdit={() => {
setIsCertificateDetailOpen(false);
setIsCertificateEditorOpen(true);
}}
onDelete={() => {
setIsCertificateDetailOpen(false);
handleDeleteCertificate();
}}
deleting={deleteCertificateMutation.isPending}
/>
<CertificateEditorModal
certificateId={certificate?.id ?? null}
isOpen={isCertificateEditorOpen}
onClose={() => setIsCertificateEditorOpen(false)}
onSaved={(updatedCertificate) => {
setFeedback({
tone: 'success',
message: `证书 ${updatedCertificate.name} 已更新。`,
});
}}
/>
</>
);
}
@@ -22,6 +22,8 @@ import {
getTlsCertificates,
} from '@/features/tls-certificates/api/tls-certificates';
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
import { CertificateDetailModal } from '@/features/websites/components/certificate-detail-modal';
import { CertificateEditorModal } from '@/features/websites/components/certificate-editor-modal';
import { CertificateImportModal } from '@/features/websites/components/certificate-import-modal';
import { WebsiteEditorModal } from '@/features/websites/components/website-editor-modal';
import {
@@ -48,6 +50,11 @@ export function WebsitesPage() {
const [isWebsiteModalOpen, setIsWebsiteModalOpen] = useState(false);
const [isCertificateImportOpen, setIsCertificateImportOpen] = useState(false);
const [isCertificateListOpen, setIsCertificateListOpen] = useState(false);
const [selectedCertificateId, setSelectedCertificateId] = useState<
number | null
>(null);
const [isCertificateDetailOpen, setIsCertificateDetailOpen] = useState(false);
const [isCertificateEditorOpen, setIsCertificateEditorOpen] = useState(false);
const [preferredCertificateId, setPreferredCertificateId] = useState<
number | null
>(null);
@@ -123,6 +130,16 @@ export function WebsitesPage() {
deleteCertificateMutation.mutate(certificate.id);
};
const handleOpenCertificateDetail = (certificate: TlsCertificateItem) => {
setSelectedCertificateId(certificate.id);
setIsCertificateDetailOpen(true);
};
const handleOpenCertificateEditor = (certificate: TlsCertificateItem) => {
setSelectedCertificateId(certificate.id);
setIsCertificateEditorOpen(true);
};
return (
<>
<div className="space-y-6">
@@ -342,14 +359,30 @@ export function WebsitesPage() {
</div>
</div>
<DangerButton
type="button"
onClick={() => handleDeleteCertificate(certificate)}
disabled={deleteCertificateMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
<div className="flex flex-wrap gap-2">
<SecondaryButton
type="button"
onClick={() => handleOpenCertificateDetail(certificate)}
className="px-3 py-2 text-xs"
>
查看
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() => handleOpenCertificateEditor(certificate)}
className="px-3 py-2 text-xs"
>
编辑
</SecondaryButton>
<DangerButton
type="button"
onClick={() => handleDeleteCertificate(certificate)}
disabled={deleteCertificateMutation.isPending}
className="px-3 py-2 text-xs"
>
删除
</DangerButton>
</div>
</div>
</div>
);
@@ -358,6 +391,38 @@ export function WebsitesPage() {
)}
</div>
</AppModal>
<CertificateDetailModal
certificateId={selectedCertificateId}
isOpen={isCertificateDetailOpen}
onClose={() => setIsCertificateDetailOpen(false)}
onEdit={() => {
setIsCertificateDetailOpen(false);
setIsCertificateEditorOpen(true);
}}
onDelete={() => {
const certificate = certificates.find(
(item) => item.id === selectedCertificateId,
);
if (certificate) {
setIsCertificateDetailOpen(false);
handleDeleteCertificate(certificate);
}
}}
deleting={deleteCertificateMutation.isPending}
/>
<CertificateEditorModal
certificateId={selectedCertificateId}
isOpen={isCertificateEditorOpen}
onClose={() => setIsCertificateEditorOpen(false)}
onSaved={(certificate) => {
setFeedback({
tone: 'success',
message: `证书 ${certificate.name} 已更新。`,
});
}}
/>
</>
);
}