mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 14:56:38 +08:00
feat: 添加TLS证书详情和编辑功能,更新相关API和前端组件
This commit is contained in:
@@ -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