"use client";
import { useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { KeySchemaBuilder } from "@/components/KeySchemaBuilder";
import {
encrypt,
normalizeInput,
validateSelections,
type KeySelection,
type Language,
type KeyType,
} from "@/lib/crypto";
import { getPlanLimits, type UserPlan } from "@/lib/plans";
import { trackEvent } from "@/lib/analytics";
// Initial: name + custom_question in random order
function initSelections(): KeySelection[] {
const base: KeySelection[] = [
{ type: "name", value: "" },
{ type: "custom_question", question: "", value: "" },
];
return Math.random() > 0.5 ? base : [base[1], base[0]];
}
// ─── Asset Entry ──────────────────────────────────────────
export interface AssetEntry {
id: string;
category: string;
content: string;
file?: {
name: string;
type: string;
size: number;
data: string; // base64
};
}
// ─── VaultPayload (sent to server — no values / plaintext) ─
export interface KeySchemaItemSafe {
type: KeyType;
question?: string;
}
export interface VaultPayload {
ciphertext: string;
salt: string;
iv: string;
keySchema: KeySchemaItemSafe[];
keyLanguage: Language;
summary: {
entryCount: number;
categories: string[];
};
}
// ─── Asset Editor ─────────────────────────────────────────
interface AssetEditorProps {
entries: AssetEntry[];
onChange: (entries: AssetEntry[]) => void;
userPlan?: UserPlan;
}
function CategorySelect({
value,
onChange,
options,
t
}: {
value: string;
onChange: (val: string) => void;
options: string[];
t: (key: string) => string;
}) {
const [open, setOpen] = useState(false);
const isCustom = !options.includes(value) && value !== "__custom__";
const displayValue = isCustom ? value : (value === "__custom__" ? t("customCategory") : value);
return (
{open && (
<>
setOpen(false)} />
{options.map((opt) => (
))}
>
)}
);
}
function AssetEditor({ entries, onChange, userPlan = "free" }: AssetEditorProps) {
const t = useTranslations("VaultForm");
const { fileUpload, maxFileSize } = getPlanLimits(userPlan);
const PRESET_CATEGORIES = [
t("categories.privateKey"),
t("categories.crypto"),
t("categories.bank"),
t("categories.hardware"),
t("categories.passwords"),
t("categories.property"),
t("categories.stocks"),
t("categories.other"),
];
function addEntry() {
const id = Date.now().toString();
onChange([...entries, { id, category: PRESET_CATEGORIES[0], content: "" }]);
}
function removeEntry(id: string) {
onChange(entries.filter((e) => e.id !== id));
}
function moveUp(index: number) {
if (index === 0) return;
const next = [...entries];
[next[index - 1], next[index]] = [next[index], next[index - 1]];
onChange(next);
}
function moveDown(index: number) {
if (index === entries.length - 1) return;
const next = [...entries];
[next[index], next[index + 1]] = [next[index + 1], next[index]];
onChange(next);
}
function updateField(id: string, field: keyof AssetEntry, value: string) {
onChange(entries.map((e) => (e.id === id ? { ...e, [field]: value } : e)));
}
function handleFileUpload(id: string, file: File) {
// Check file size limit
if (file.size > maxFileSize) {
alert(t("fileTooLarge", { max: (maxFileSize / 1024 / 1024).toFixed(0) }));
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target?.result as string;
const data = base64.split(',')[1]; // Remove data:mime;base64, prefix
onChange(entries.map((entry) =>
entry.id === id
? {
...entry,
file: {
name: file.name,
type: file.type,
size: file.size,
data,
}
}
: entry
));
};
reader.readAsDataURL(file);
}
function removeFile(id: string) {
onChange(entries.map((entry) =>
entry.id === id ? { ...entry, file: undefined } : entry
));
}
function getPlaceholder(category: string): string {
if (category.includes("Private Key") || category.includes("Mnemonic") || category.includes("私钥") || category.includes("助记词"))
return t("placeholders.privateKey");
if (category.includes("Crypto") || category.includes("加密货币"))
return t("placeholders.crypto");
if (category.includes("Bank") || category.includes("银行"))
return t("placeholders.bank");
if (category.includes("Hardware") || category.includes("硬件"))
return t("placeholders.hardware");
if (category.includes("Password") || category.includes("Login") || category.includes("账号"))
return t("placeholders.passwords");
if (category.includes("Property") || category.includes("Safe") || category.includes("房产"))
return t("placeholders.property");
if (category.includes("Stock") || category.includes("Fund") || category.includes("股票"))
return t("placeholders.stocks");
return t("placeholders.default");
}
return (
{entries.length === 0 && (
⌨️
{t("emptyHint")}
)}
{entries.map((entry, i) => {
const isCustom = !PRESET_CATEGORIES.includes(entry.category);
return (
{/* Header / Controls */}
{String(i + 1).padStart(2, '0')}
updateField(entry.id, "category", val === "__custom__" ? "" : val)}
/>
{/* Action buttons (Fade in on hover) */}
{/* Custom category input */}
{isCustom && (
updateField(entry.id, "category", e.target.value)}
className="w-full bg-transparent text-xs font-mono text-emerald-600 border-b border-dashed border-gray-200 pb-1 focus:outline-none focus:border-emerald-500/50 transition-colors"
/>
)}
{/* Content Editor */}
{/* File Upload Area */}
{!fileUpload ? (
📎
{t("fileUploadPremium")}
) : entry.file ? (
📎
{entry.file.name}
{(entry.file.size / 1024).toFixed(1)} KB
) : (
)}
);
})}
);
}
// ─── VaultForm main component ─────────────────────────────
interface VaultFormProps {
onPayloadReady: (
payload: VaultPayload,
draft: {
entries: AssetEntry[];
selections: KeySelection[];
language: Language;
}
) => void;
initialEntries?: AssetEntry[];
initialSelections?: KeySelection[];
initialLanguage?: Language;
userPlan?: UserPlan;
}
export function VaultForm({
onPayloadReady,
initialEntries,
initialSelections,
initialLanguage,
userPlan = "free",
}: VaultFormProps) {
const t = useTranslations("VaultForm");
const locale = useLocale();
const [entries, setEntries] = useState
(() => initialEntries ?? []);
const [selections, setSelections] = useState(() => initialSelections ?? initSelections());
const [language, setLanguage] = useState(initialLanguage ?? "en");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<{ title: string; detail: string } | null>(null);
async function handleEncrypt(e: React.FormEvent) {
e.preventDefault();
setError(null);
const nonEmpty = entries.filter((en) => en.content.trim());
if (nonEmpty.length === 0) {
setError({
title: t("inputErrorTitle"),
detail: t("noEntriesError"),
});
return;
}
try {
validateSelections(selections);
} catch (err) {
setError({
title: t("schemaErrorTitle"),
detail: err instanceof Error ? err.message : t("schemaValidationError"),
});
return;
}
const customWithoutQuestion = selections.find(
(s) => s.type === "custom_question" && !s.question?.trim()
);
if (customWithoutQuestion) {
setError({
title: t("schemaErrorTitle"),
detail: t("questionRequiredError"),
});
return;
}
const recoveryPhrase = selections.find((s) => s.type === "custom_question");
if (
!recoveryPhrase ||
[...normalizeInput(recoveryPhrase.value, language)].length < 20
) {
setError({
title: t("schemaErrorTitle"),
detail: t("recoveryPhraseWeakError"),
});
return;
}
setLoading(true);
trackEvent("Vault Encryption Started", {
locale,
plan: userPlan,
entries: nonEmpty.length,
has_file: nonEmpty.some((entry) => Boolean(entry.file)),
});
try {
const plaintext = JSON.stringify({
v: 1,
entries: nonEmpty.map(({ category, content, file }) => ({
category,
content,
...(file ? { file } : {})
})),
});
const payload = await encrypt(plaintext, selections, language);
trackEvent("Vault Encrypted", {
locale,
plan: userPlan,
entries: nonEmpty.length,
has_file: nonEmpty.some((entry) => Boolean(entry.file)),
});
const keySchema: KeySchemaItemSafe[] = selections.map((s) => ({
type: s.type,
...(s.type === "custom_question" ? { question: s.question } : {}),
}));
const categories = Array.from(
new Set(
nonEmpty
.map((entry) => entry.category.trim())
.filter(Boolean)
)
);
onPayloadReady(
{
...payload,
keySchema,
keyLanguage: language,
summary: {
entryCount: nonEmpty.length,
categories,
},
},
{
entries,
selections,
language,
}
);
} catch (err) {
trackEvent("Vault Encryption Failed", { locale, plan: userPlan });
setError({
title: t("encryptErrorTitle"),
detail: err instanceof Error ? err.message : String(err),
});
} finally {
setLoading(false);
}
}
return (
);
}