mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +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
|
||||
// @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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} 已更新。`,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user