refactor: move S/MIME to generic crypto plugin hooks
This commit is contained in:
@@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ShieldCheck, X } from "lucide-react";
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
|
||||
|
||||
interface SmimeCertificateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
record: SmimeKeyRecord | SmimePublicCert | null;
|
||||
type: "private" | "public";
|
||||
}
|
||||
|
||||
export function SmimeCertificateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
record,
|
||||
type: _type,
|
||||
}: SmimeCertificateModalProps) {
|
||||
const t = useTranslations("smime");
|
||||
const id = useId();
|
||||
|
||||
const dialogRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
onEscape: onClose,
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
if (!isOpen || !record) return null;
|
||||
|
||||
const isExpired = new Date(record.notAfter) < new Date();
|
||||
const isNotYetValid = new Date(record.notBefore) > new Date();
|
||||
|
||||
const rows: { label: string; value: string }[] = [
|
||||
{ label: t("cert_subject"), value: record.subject ?? "" },
|
||||
{ label: t("cert_issuer"), value: record.issuer ?? "" },
|
||||
{ label: t("cert_email"), value: record.email },
|
||||
{
|
||||
label: t("cert_validity"),
|
||||
value: `${new Date(record.notBefore).toLocaleDateString()} - ${new Date(record.notAfter).toLocaleDateString()}`,
|
||||
},
|
||||
{ label: t("cert_fingerprint"), value: record.fingerprint },
|
||||
];
|
||||
|
||||
if ("serialNumber" in record) {
|
||||
rows.splice(2, 0, { label: t("cert_serial"), value: record.serialNumber });
|
||||
}
|
||||
|
||||
if ("algorithm" in record) {
|
||||
rows.push({ label: t("cert_algorithm"), value: record.algorithm });
|
||||
}
|
||||
|
||||
if ("capabilities" in record) {
|
||||
const caps: string[] = [];
|
||||
if (record.capabilities.canSign) caps.push(t("cap_sign"));
|
||||
if (record.capabilities.canEncrypt) caps.push(t("cap_encrypt"));
|
||||
rows.push({ label: t("cert_capabilities"), value: caps.join(", ") || t("cap_none") });
|
||||
}
|
||||
|
||||
if ("source" in record) {
|
||||
rows.push({ label: t("cert_source"), value: record.source });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`${id}-title`}
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-lg animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 pb-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<ShieldCheck className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">
|
||||
{t("certificate_details")}
|
||||
</h2>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
{(isExpired || isNotYetValid) && (
|
||||
<div className="px-3 py-2 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
{isExpired ? t("cert_expired") : t("cert_not_yet_valid")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="text-sm text-foreground mt-0.5 break-all font-mono">
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end px-6 pb-6">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useId } from "react";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { KeyRound, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface SmimePassphraseDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (passphrase: string) => void | Promise<void>;
|
||||
title: string;
|
||||
description?: string;
|
||||
submitText?: string;
|
||||
error?: string | null;
|
||||
/** Show a second passphrase field for import/export confirmation. */
|
||||
showConfirm?: boolean;
|
||||
}
|
||||
|
||||
export function SmimePassphraseDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
title,
|
||||
description,
|
||||
submitText,
|
||||
error,
|
||||
showConfirm = false,
|
||||
}: SmimePassphraseDialogProps) {
|
||||
const t = useTranslations("smime");
|
||||
const id = useId();
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const dialogRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
onEscape: onClose,
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const mismatch = showConfirm && passphrase !== confirm && confirm.length > 0;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!passphrase || (showConfirm && passphrase !== confirm)) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(passphrase);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setPassphrase("");
|
||||
setConfirm("");
|
||||
setShowPassword(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={`${id}-title`}
|
||||
aria-describedby={description ? `${id}-desc` : undefined}
|
||||
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2
|
||||
id={`${id}-title`}
|
||||
className="text-lg font-semibold text-foreground"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p
|
||||
id={`${id}-desc`}
|
||||
className="text-sm text-muted-foreground mt-1"
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder={t("passphrase_placeholder")}
|
||||
autoFocus
|
||||
className="pr-10"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? t("hide_passphrase") : t("show_passphrase")}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showConfirm && (
|
||||
<div>
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder={t("confirm_passphrase_placeholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{mismatch && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{t("passphrase_mismatch")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 px-6 pb-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!passphrase || isSubmitting || (showConfirm && passphrase !== confirm)}
|
||||
>
|
||||
{isSubmitting ? t("processing") : (submitText ?? t("unlock"))}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,549 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Upload,
|
||||
Trash2,
|
||||
Eye,
|
||||
Lock,
|
||||
Unlock,
|
||||
Download,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch } from "@/components/settings/settings-section";
|
||||
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
|
||||
import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { useIdentityStore } from "@/stores/identity-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export";
|
||||
import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types";
|
||||
|
||||
export function SmimeSettings() {
|
||||
const t = useTranslations("smime");
|
||||
const {
|
||||
keyRecords,
|
||||
publicCerts,
|
||||
identityKeyBindings,
|
||||
defaultSignIdentity,
|
||||
defaultEncrypt,
|
||||
autoImportSignerCerts,
|
||||
isLoading,
|
||||
error,
|
||||
load,
|
||||
importPKCS12,
|
||||
removeKeyRecord,
|
||||
removePublicCert,
|
||||
bindIdentityToKey,
|
||||
unlockKey,
|
||||
lockKey,
|
||||
setSignDefault,
|
||||
setEncryptDefault,
|
||||
setAutoImportSignerCerts,
|
||||
isKeyUnlocked,
|
||||
setError,
|
||||
} = useSmimeStore();
|
||||
|
||||
const { identities } = useIdentityStore();
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
|
||||
// Local UI state
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [unlockDialogOpen, setUnlockDialogOpen] = useState(false);
|
||||
const [unlockTargetId, setUnlockTargetId] = useState<string | null>(null);
|
||||
const [certModalRecord, setCertModalRecord] = useState<SmimeKeyRecord | SmimePublicCert | null>(null);
|
||||
const [certModalType, setCertModalType] = useState<"private" | "public">("private");
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [unlockError, setUnlockError] = useState<string | null>(null);
|
||||
const [pendingFile, setPendingFile] = useState<ArrayBuffer | null>(null);
|
||||
const [pendingP12Pass, setPendingP12Pass] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pubCertInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// State for the two-step PKCS#12 flow
|
||||
const [importStep, setImportStep] = useState<"p12" | "storage">("p12");
|
||||
|
||||
// Export flow state
|
||||
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||
const [exportTargetRecord, setExportTargetRecord] = useState<SmimeKeyRecord | null>(null);
|
||||
const [exportStep, setExportStep] = useState<"storage" | "export">("storage");
|
||||
const [exportStoragePass, setExportStoragePass] = useState("");
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
load(activeAccountId ?? undefined);
|
||||
}, [load, activeAccountId]);
|
||||
|
||||
// ── PKCS#12 import flow ────────────────────────────────────────
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setPendingFile(reader.result as ArrayBuffer);
|
||||
setImportStep("p12");
|
||||
setImportError(null);
|
||||
setImportDialogOpen(true);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
// Reset so same file can be re-selected
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleImportSubmit = async (passphrase: string) => {
|
||||
if (importStep === "p12") {
|
||||
setPendingP12Pass(passphrase);
|
||||
setImportStep("storage");
|
||||
setImportError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Storage passphrase step
|
||||
if (!pendingFile) return;
|
||||
try {
|
||||
await importPKCS12(pendingFile, pendingP12Pass, passphrase);
|
||||
setImportDialogOpen(false);
|
||||
setPendingFile(null);
|
||||
setPendingP12Pass("");
|
||||
setImportError(null);
|
||||
} catch (err) {
|
||||
setImportError(err instanceof Error ? err.message : "Import failed");
|
||||
}
|
||||
};
|
||||
|
||||
// ── Public cert import ─────────────────────────────────────────
|
||||
|
||||
const handlePublicCertFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const store = useSmimeStore.getState();
|
||||
await store.importPublicCert(reader.result as ArrayBuffer, "manual");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to import certificate");
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
// ── Unlock ─────────────────────────────────────────────────────
|
||||
|
||||
const handleUnlockRequest = (id: string) => {
|
||||
setUnlockTargetId(id);
|
||||
setUnlockError(null);
|
||||
setUnlockDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleUnlockSubmit = async (passphrase: string) => {
|
||||
if (!unlockTargetId) return;
|
||||
try {
|
||||
await unlockKey(unlockTargetId, passphrase);
|
||||
setUnlockDialogOpen(false);
|
||||
setUnlockTargetId(null);
|
||||
setUnlockError(null);
|
||||
} catch (err) {
|
||||
setUnlockError(err instanceof Error ? err.message : "Unlock failed");
|
||||
}
|
||||
};
|
||||
|
||||
// ── Export flow ────────────────────────────────────────────────
|
||||
|
||||
const handleExportRequest = (record: SmimeKeyRecord) => {
|
||||
setExportTargetRecord(record);
|
||||
setExportStep("storage");
|
||||
setExportStoragePass("");
|
||||
setExportError(null);
|
||||
setExportDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleExportSubmit = async (passphrase: string) => {
|
||||
if (!exportTargetRecord) return;
|
||||
|
||||
if (exportStep === "storage") {
|
||||
// Verify storage passphrase by attempting to decrypt
|
||||
try {
|
||||
const { decryptPrivateKeyBytes } = await import("@/lib/smime/pkcs12-import");
|
||||
await decryptPrivateKeyBytes(exportTargetRecord, passphrase);
|
||||
setExportStoragePass(passphrase);
|
||||
setExportStep("export");
|
||||
setExportError(null);
|
||||
} catch {
|
||||
setExportError(t("incorrect_passphrase"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Export passphrase step
|
||||
try {
|
||||
const p12Bytes = await exportPkcs12(exportTargetRecord, exportStoragePass, passphrase);
|
||||
const filename = `${exportTargetRecord.email.replace(/[^a-zA-Z0-9.-]/g, '_')}.p12`;
|
||||
downloadPkcs12(p12Bytes, filename);
|
||||
setExportDialogOpen(false);
|
||||
setExportTargetRecord(null);
|
||||
setExportStoragePass("");
|
||||
setExportError(null);
|
||||
} catch (err) {
|
||||
setExportError(err instanceof Error ? err.message : "Export failed");
|
||||
}
|
||||
};
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
const isExpired = (dateStr: string) => new Date(dateStr) < new Date();
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const getBoundIdentityNames = (keyId: string): string[] => {
|
||||
return Object.entries(identityKeyBindings)
|
||||
.filter(([, kId]) => kId === keyId)
|
||||
.map(([identityId]) => {
|
||||
const identity = identities.find((i) => i.id === identityId);
|
||||
return identity?.email ?? identityId;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{error && (
|
||||
<div className="px-4 py-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Your Certificates ──────────────────────────────────── */}
|
||||
<SettingsSection
|
||||
title={t("your_certificates")}
|
||||
description={t("your_certificates_desc")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{keyRecords.map((record) => {
|
||||
const expired = isExpired(record.notAfter);
|
||||
const unlocked = isKeyUnlocked(record.id);
|
||||
const boundIdentities = getBoundIdentityNames(record.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${expired ? "bg-destructive/10" : "bg-primary/10"}`}>
|
||||
{expired ? (
|
||||
<ShieldAlert className="w-4 h-4 text-destructive" />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{record.email || record.subject}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{record.issuer} · {t("expires")} {formatDate(record.notAfter)}
|
||||
{expired && <span className="text-destructive ml-1">({t("expired")})</span>}
|
||||
</p>
|
||||
{boundIdentities.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("bound_to")}: {boundIdentities.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{unlocked ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => lockKey(record.id)}
|
||||
title={t("lock")}
|
||||
>
|
||||
<Unlock className="w-4 h-4 text-green-600" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleUnlockRequest(record.id)}
|
||||
title={t("unlock")}
|
||||
>
|
||||
<Lock className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setCertModalRecord(record);
|
||||
setCertModalType("private");
|
||||
}}
|
||||
title={t("details")}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleExportRequest(record)}
|
||||
title={t("export")}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeKeyRecord(record.id)}
|
||||
title={t("delete")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{keyRecords.length === 0 && !isLoading && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("no_certificates")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".p12,.pfx"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="mt-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{t("import_pkcs12")}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
{/* ── Recipient Certificates ─────────────────────────────── */}
|
||||
<SettingsSection
|
||||
title={t("recipient_certificates")}
|
||||
description={t("recipient_certificates_desc")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{publicCerts.map((cert) => {
|
||||
const expired = isExpired(cert.notAfter);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cert.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{cert.email || cert.subject}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{cert.issuer} · {cert.source}
|
||||
{expired && <span className="text-destructive ml-1">({t("expired")})</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setCertModalRecord(cert);
|
||||
setCertModalType("public");
|
||||
}}
|
||||
title={t("details")}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removePublicCert(cert.id)}
|
||||
title={t("delete")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{publicCerts.length === 0 && !isLoading && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t("no_recipient_certs")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={pubCertInputRef}
|
||||
type="file"
|
||||
accept=".pem,.cer,.crt,.der"
|
||||
className="hidden"
|
||||
onChange={handlePublicCertFile}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => pubCertInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
className="mt-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{t("import_public_cert")}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
{/* ── Identity Bindings ──────────────────────────────────── */}
|
||||
{identities.length > 0 && keyRecords.length > 0 && (
|
||||
<SettingsSection
|
||||
title={t("identity_bindings")}
|
||||
description={t("identity_bindings_desc")}
|
||||
>
|
||||
{identities.map((identity) => {
|
||||
const boundKeyId = identityKeyBindings[identity.id];
|
||||
return (
|
||||
<SettingItem key={identity.id} label={identity.email}>
|
||||
<select
|
||||
value={boundKeyId ?? ""}
|
||||
onChange={(e) =>
|
||||
bindIdentityToKey(identity.id, e.target.value || null)
|
||||
}
|
||||
className="text-sm bg-background border border-border rounded-md px-2 py-1"
|
||||
>
|
||||
<option value="">{t("no_key_bound")}</option>
|
||||
{keyRecords.map((kr) => (
|
||||
<option key={kr.id} value={kr.id}>
|
||||
{kr.email} ({kr.algorithm})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</SettingItem>
|
||||
);
|
||||
})}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* ── Defaults ───────────────────────────────────────────── */}
|
||||
<SettingsSection
|
||||
title={t("defaults_title")}
|
||||
description={t("defaults_desc")}
|
||||
>
|
||||
<SettingItem
|
||||
label={t("encrypt_by_default")}
|
||||
description={t("encrypt_by_default_desc")}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={defaultEncrypt}
|
||||
onChange={setEncryptDefault}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t("auto_import_signer_certs")}
|
||||
description={t("auto_import_signer_certs_desc")}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={autoImportSignerCerts}
|
||||
onChange={setAutoImportSignerCerts}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{identities.map((identity) => {
|
||||
const bound = identityKeyBindings[identity.id];
|
||||
if (!bound) return null;
|
||||
return (
|
||||
<SettingItem
|
||||
key={identity.id}
|
||||
label={`${t("sign_default_for")} ${identity.email}`}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={defaultSignIdentity[identity.id] ?? false}
|
||||
onChange={(v) => setSignDefault(identity.id, v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
);
|
||||
})}
|
||||
</SettingsSection>
|
||||
|
||||
{/* ── Dialogs ────────────────────────────────────────────── */}
|
||||
<SmimePassphraseDialog
|
||||
isOpen={importDialogOpen}
|
||||
onClose={() => {
|
||||
setImportDialogOpen(false);
|
||||
setPendingFile(null);
|
||||
setPendingP12Pass("");
|
||||
setImportError(null);
|
||||
setImportStep("p12");
|
||||
}}
|
||||
onSubmit={handleImportSubmit}
|
||||
title={importStep === "p12" ? t("enter_p12_passphrase") : t("enter_storage_passphrase")}
|
||||
description={importStep === "p12" ? t("p12_passphrase_desc") : t("storage_passphrase_desc")}
|
||||
submitText={importStep === "p12" ? t("next") : t("import")}
|
||||
error={importError}
|
||||
showConfirm={importStep === "storage"}
|
||||
/>
|
||||
|
||||
<SmimePassphraseDialog
|
||||
isOpen={unlockDialogOpen}
|
||||
onClose={() => {
|
||||
setUnlockDialogOpen(false);
|
||||
setUnlockTargetId(null);
|
||||
setUnlockError(null);
|
||||
}}
|
||||
onSubmit={handleUnlockSubmit}
|
||||
title={t("unlock_key")}
|
||||
description={t("unlock_key_desc")}
|
||||
error={unlockError}
|
||||
/>
|
||||
|
||||
<SmimeCertificateModal
|
||||
isOpen={!!certModalRecord}
|
||||
onClose={() => setCertModalRecord(null)}
|
||||
record={certModalRecord}
|
||||
type={certModalType}
|
||||
/>
|
||||
|
||||
<SmimePassphraseDialog
|
||||
isOpen={exportDialogOpen}
|
||||
onClose={() => {
|
||||
setExportDialogOpen(false);
|
||||
setExportTargetRecord(null);
|
||||
setExportStoragePass("");
|
||||
setExportError(null);
|
||||
setExportStep("storage");
|
||||
}}
|
||||
onSubmit={handleExportSubmit}
|
||||
title={exportStep === "storage" ? t("enter_storage_passphrase") : t("enter_export_passphrase")}
|
||||
description={exportStep === "storage" ? t("export_storage_desc") : t("export_passphrase_desc")}
|
||||
submitText={exportStep === "storage" ? t("next") : t("export")}
|
||||
error={exportError}
|
||||
showConfirm={exportStep === "export"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user