mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
feat: 添加TLS证书详情和编辑功能,更新相关API和前端组件
This commit is contained in:
@@ -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
|
// CreateTLSCertificate godoc
|
||||||
// @Summary Create TLS certificate from PEM
|
// @Summary Create TLS certificate from PEM
|
||||||
// @Tags TLSCertificates
|
// @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
|
// ImportTLSCertificateFile godoc
|
||||||
// @Summary Import TLS certificate from files
|
// @Summary Import TLS certificate from files
|
||||||
// @Tags TLSCertificates
|
// @Tags TLSCertificates
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ func (certificate *TLSCertificate) Insert() error {
|
|||||||
return DB.Create(certificate).Error
|
return DB.Create(certificate).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (certificate *TLSCertificate) Update() error {
|
||||||
|
return DB.Save(certificate).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (certificate *TLSCertificate) Delete() error {
|
func (certificate *TLSCertificate) Delete() error {
|
||||||
return DB.Delete(certificate).Error
|
return DB.Delete(certificate).Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
tlsCertificateRoute.Use(middleware.AdminAuth())
|
tlsCertificateRoute.Use(middleware.AdminAuth())
|
||||||
{
|
{
|
||||||
tlsCertificateRoute.GET("/", controller.GetTLSCertificates)
|
tlsCertificateRoute.GET("/", controller.GetTLSCertificates)
|
||||||
|
tlsCertificateRoute.GET("/:id", controller.GetTLSCertificate)
|
||||||
tlsCertificateRoute.POST("/", controller.CreateTLSCertificate)
|
tlsCertificateRoute.POST("/", controller.CreateTLSCertificate)
|
||||||
|
tlsCertificateRoute.PUT("/:id", controller.UpdateTLSCertificate)
|
||||||
tlsCertificateRoute.POST("/import-file", controller.ImportTLSCertificateFile)
|
tlsCertificateRoute.POST("/import-file", controller.ImportTLSCertificateFile)
|
||||||
tlsCertificateRoute.DELETE("/:id", controller.DeleteTLSCertificate)
|
tlsCertificateRoute.DELETE("/:id", controller.DeleteTLSCertificate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,25 @@ func TestPhase1HTTPSAndCertificateImportLifecycle(t *testing.T) {
|
|||||||
t.Fatal("expected manual certificate import to persist certificate")
|
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"})
|
fileCertPEM, fileKeyPEM := generateCertificatePairForRouterTest(t, []string{"upload.example.com"})
|
||||||
multipartResp := performMultipartRequest(t, engine, token, "/api/tls-certificates/import-file", map[string]string{
|
multipartResp := performMultipartRequest(t, engine, token, "/api/tls-certificates/import-file", map[string]string{
|
||||||
"name": "upload-example",
|
"name": "upload-example",
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ func ListTLSCertificates() ([]*model.TLSCertificate, error) {
|
|||||||
return model.ListTLSCertificates()
|
return model.ListTLSCertificates()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetTLSCertificate(id uint) (*model.TLSCertificate, error) {
|
||||||
|
return model.GetTLSCertificateByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
func CreateTLSCertificate(input TLSCertificateInput) (*model.TLSCertificate, error) {
|
func CreateTLSCertificate(input TLSCertificateInput) (*model.TLSCertificate, error) {
|
||||||
certificate, err := buildTLSCertificate(nil, input)
|
certificate, err := buildTLSCertificate(nil, input)
|
||||||
if err != nil {
|
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 {
|
func DeleteTLSCertificate(id uint) error {
|
||||||
var routeCount int64
|
var routeCount int64
|
||||||
if err := model.DB.Model(&model.ProxyRoute{}).Where("cert_id = ?", id).Count(&routeCount).Error; err != nil {
|
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 { apiRequest } from '@/lib/api/client';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
TlsCertificateDetailItem,
|
||||||
TlsCertificateFileImportPayload,
|
TlsCertificateFileImportPayload,
|
||||||
TlsCertificateItem,
|
TlsCertificateItem,
|
||||||
TlsCertificateMutationPayload,
|
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) {
|
export function importTlsCertificateFiles(payload: TlsCertificateFileImportPayload) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('name', payload.name);
|
formData.append('name', payload.name);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export interface TlsCertificateItem {
|
export interface TlsCertificateItem {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
cert_pem?: string;
|
||||||
|
key_pem?: string;
|
||||||
not_before: string;
|
not_before: string;
|
||||||
not_after: string;
|
not_after: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
@@ -8,6 +10,11 @@ export interface TlsCertificateItem {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TlsCertificateDetailItem extends TlsCertificateItem {
|
||||||
|
cert_pem: string;
|
||||||
|
key_pem: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TlsCertificateMutationPayload {
|
export interface TlsCertificateMutationPayload {
|
||||||
name: string;
|
name: string;
|
||||||
cert_pem: 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,
|
deleteTlsCertificate,
|
||||||
getTlsCertificates,
|
getTlsCertificates,
|
||||||
} from '@/features/tls-certificates/api/tls-certificates';
|
} 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 { CertificateImportModal } from '@/features/websites/components/certificate-import-modal';
|
||||||
import { WebsiteEditorModal } from '@/features/websites/components/website-editor-modal';
|
import { WebsiteEditorModal } from '@/features/websites/components/website-editor-modal';
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +52,8 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
|
|||||||
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||||
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||||||
const [isCertificateImportOpen, setIsCertificateImportOpen] = useState(false);
|
const [isCertificateImportOpen, setIsCertificateImportOpen] = useState(false);
|
||||||
|
const [isCertificateDetailOpen, setIsCertificateDetailOpen] = useState(false);
|
||||||
|
const [isCertificateEditorOpen, setIsCertificateEditorOpen] = useState(false);
|
||||||
const [preferredCertificateId, setPreferredCertificateId] = useState<
|
const [preferredCertificateId, setPreferredCertificateId] = useState<
|
||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -339,6 +343,20 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
|
|||||||
<p>到期时间:{formatDateTime(certificate.not_after)}</p>
|
<p>到期时间:{formatDateTime(certificate.not_after)}</p>
|
||||||
<p>备注:{certificate.remark || '暂无备注'}</p>
|
<p>备注:{certificate.remark || '暂无备注'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCertificateDetailOpen(true)}
|
||||||
|
>
|
||||||
|
查看证书
|
||||||
|
</SecondaryButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsCertificateEditorOpen(true)}
|
||||||
|
>
|
||||||
|
编辑证书
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
<DangerButton
|
<DangerButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDeleteCertificate}
|
onClick={handleDeleteCertificate}
|
||||||
@@ -470,6 +488,33 @@ export function WebsiteDetailPage({ websiteId }: { websiteId: string }) {
|
|||||||
setIsEditorOpen(true);
|
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,
|
getTlsCertificates,
|
||||||
} from '@/features/tls-certificates/api/tls-certificates';
|
} from '@/features/tls-certificates/api/tls-certificates';
|
||||||
import type { TlsCertificateItem } from '@/features/tls-certificates/types';
|
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 { CertificateImportModal } from '@/features/websites/components/certificate-import-modal';
|
||||||
import { WebsiteEditorModal } from '@/features/websites/components/website-editor-modal';
|
import { WebsiteEditorModal } from '@/features/websites/components/website-editor-modal';
|
||||||
import {
|
import {
|
||||||
@@ -48,6 +50,11 @@ export function WebsitesPage() {
|
|||||||
const [isWebsiteModalOpen, setIsWebsiteModalOpen] = useState(false);
|
const [isWebsiteModalOpen, setIsWebsiteModalOpen] = useState(false);
|
||||||
const [isCertificateImportOpen, setIsCertificateImportOpen] = useState(false);
|
const [isCertificateImportOpen, setIsCertificateImportOpen] = useState(false);
|
||||||
const [isCertificateListOpen, setIsCertificateListOpen] = 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<
|
const [preferredCertificateId, setPreferredCertificateId] = useState<
|
||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -123,6 +130,16 @@ export function WebsitesPage() {
|
|||||||
deleteCertificateMutation.mutate(certificate.id);
|
deleteCertificateMutation.mutate(certificate.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenCertificateDetail = (certificate: TlsCertificateItem) => {
|
||||||
|
setSelectedCertificateId(certificate.id);
|
||||||
|
setIsCertificateDetailOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCertificateEditor = (certificate: TlsCertificateItem) => {
|
||||||
|
setSelectedCertificateId(certificate.id);
|
||||||
|
setIsCertificateEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -342,14 +359,30 @@ export function WebsitesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DangerButton
|
<div className="flex flex-wrap gap-2">
|
||||||
type="button"
|
<SecondaryButton
|
||||||
onClick={() => handleDeleteCertificate(certificate)}
|
type="button"
|
||||||
disabled={deleteCertificateMutation.isPending}
|
onClick={() => handleOpenCertificateDetail(certificate)}
|
||||||
className="px-3 py-2 text-xs"
|
className="px-3 py-2 text-xs"
|
||||||
>
|
>
|
||||||
删除
|
查看
|
||||||
</DangerButton>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -358,6 +391,38 @@ export function WebsitesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AppModal>
|
</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} 已更新。`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user