import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import ConfirmDialog from '@/components/ConfirmDialog'; import { calcTotpNow } from '@/lib/crypto'; import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh'; import { CheckCheck, Clipboard, CreditCard, Download, Eye, EyeOff, ExternalLink, FileKey2, Folder as FolderIcon, FolderPlus, FolderX, FolderInput, Globe, KeyRound, LayoutGrid, Paperclip, Pencil, Plus, RefreshCw, ShieldUser, Star, StarOff, StickyNote, Trash2, Upload, X, } from 'lucide-preact'; import type { Cipher, CipherAttachment, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import { t } from '@/lib/i18n'; interface VaultPageProps { ciphers: Cipher[]; folders: Folder[]; loading: boolean; emailForReprompt: string; onRefresh: () => Promise; onCreate: (draft: VaultDraft, attachments?: File[]) => Promise; onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise; onDelete: (cipher: Cipher) => Promise; onBulkDelete: (ids: string[]) => Promise; onBulkMove: (ids: string[], folderId: string | null) => Promise; onVerifyMasterPassword: (email: string, password: string) => Promise; onNotify: (type: 'success' | 'error', text: string) => void; onCreateFolder: (name: string) => Promise; onDeleteFolder: (folderId: string) => Promise; onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise; } type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; type SidebarFilter = | { kind: 'all' } | { kind: 'favorite' } | { kind: 'trash' } | { kind: 'type'; value: TypeFilter } | { kind: 'folder'; folderId: string | null }; interface TypeOption { type: number; label: string; } const CREATE_TYPE_OPTIONS: TypeOption[] = [ { type: 1, label: t('txt_login') }, { type: 3, label: t('txt_card') }, { type: 4, label: t('txt_identity') }, { type: 2, label: t('txt_note') }, { type: 5, label: t('txt_ssh_key') }, ]; function CreateTypeIcon({ type }: { type: number }) { if (type === 1) return ; if (type === 3) return ; if (type === 4) return ; if (type === 2) return ; if (type === 5) return ; return ; } const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [ { value: 0, label: t('txt_text') }, { value: 1, label: t('txt_hidden') }, { value: 2, label: t('txt_boolean') }, ]; function cipherTypeKey(type: number): TypeFilter { if (type === 1) return 'login'; if (type === 3) return 'card'; if (type === 4) return 'identity'; if (type === 2) return 'note'; return 'ssh'; } function cipherTypeLabel(type: number): string { if (type === 1) return t('txt_login'); if (type === 3) return t('txt_card'); if (type === 4) return t('txt_identity'); if (type === 2) return t('txt_secure_note'); if (type === 5) return t('txt_ssh_key'); return t('txt_item'); } function TypeIcon({ type }: { type: number }) { if (type === 1) return ; if (type === 3) return ; if (type === 4) return ; if (type === 2) return ; if (type === 5) return ; return ; } function parseFieldType(value: number | string | null | undefined): CustomFieldType { if (value === 1 || value === 2 || value === 3) return value; if (value === '1' || String(value).toLowerCase() === 'hidden') return 1; if (value === '2' || String(value).toLowerCase() === 'boolean') return 2; if (value === '3' || String(value).toLowerCase() === 'linked') return 3; return 0; } function fieldTypeLabel(type: CustomFieldType): string { if (type === 3) return t('txt_linked'); const found = FIELD_TYPE_OPTIONS.find((x) => x.value === type); return found ? found.label : t('txt_text'); } function toBooleanFieldValue(raw: string): boolean { const v = String(raw || '').trim().toLowerCase(); return v === '1' || v === 'true' || v === 'yes' || v === 'on'; } function firstCipherUri(cipher: Cipher): string { const uris = cipher.login?.uris || []; for (const uri of uris) { const raw = uri.decUri || uri.uri || ''; if (raw.trim()) return raw.trim(); } return ''; } function hostFromUri(uri: string): string { if (!uri.trim()) return ''; try { const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`; return new URL(normalized).hostname || ''; } catch { return ''; } } function createEmptyDraft(type: number): VaultDraft { return { type, favorite: false, name: '', folderId: '', notes: '', reprompt: false, loginUsername: '', loginPassword: '', loginTotp: '', loginUris: [''], loginFido2Credentials: [], cardholderName: '', cardNumber: '', cardBrand: '', cardExpMonth: '', cardExpYear: '', cardCode: '', identTitle: '', identFirstName: '', identMiddleName: '', identLastName: '', identUsername: '', identCompany: '', identSsn: '', identPassportNumber: '', identLicenseNumber: '', identEmail: '', identPhone: '', identAddress1: '', identAddress2: '', identAddress3: '', identCity: '', identState: '', identPostalCode: '', identCountry: '', sshPrivateKey: '', sshPublicKey: '', sshFingerprint: '', customFields: [], }; } function draftFromCipher(cipher: Cipher): VaultDraft { const draft = createEmptyDraft(Number(cipher.type || 1)); draft.id = cipher.id; draft.favorite = !!cipher.favorite; draft.name = cipher.decName || ''; draft.folderId = cipher.folderId || ''; draft.notes = cipher.decNotes || ''; draft.reprompt = Number(cipher.reprompt || 0) === 1; if (cipher.login) { draft.loginUsername = cipher.login.decUsername || ''; draft.loginPassword = cipher.login.decPassword || ''; draft.loginTotp = cipher.login.decTotp || ''; draft.loginUris = (cipher.login.uris || []).map((x) => x.decUri || x.uri || ''); draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials) ? cipher.login.fido2Credentials.map((credential) => ({ ...credential })) : []; if (!draft.loginUris.length) draft.loginUris = ['']; } if (cipher.card) { draft.cardholderName = cipher.card.decCardholderName || ''; draft.cardNumber = cipher.card.decNumber || ''; draft.cardBrand = cipher.card.decBrand || ''; draft.cardExpMonth = cipher.card.decExpMonth || ''; draft.cardExpYear = cipher.card.decExpYear || ''; draft.cardCode = cipher.card.decCode || ''; } if (cipher.identity) { draft.identTitle = cipher.identity.decTitle || ''; draft.identFirstName = cipher.identity.decFirstName || ''; draft.identMiddleName = cipher.identity.decMiddleName || ''; draft.identLastName = cipher.identity.decLastName || ''; draft.identUsername = cipher.identity.decUsername || ''; draft.identCompany = cipher.identity.decCompany || ''; draft.identSsn = cipher.identity.decSsn || ''; draft.identPassportNumber = cipher.identity.decPassportNumber || ''; draft.identLicenseNumber = cipher.identity.decLicenseNumber || ''; draft.identEmail = cipher.identity.decEmail || ''; draft.identPhone = cipher.identity.decPhone || ''; draft.identAddress1 = cipher.identity.decAddress1 || ''; draft.identAddress2 = cipher.identity.decAddress2 || ''; draft.identAddress3 = cipher.identity.decAddress3 || ''; draft.identCity = cipher.identity.decCity || ''; draft.identState = cipher.identity.decState || ''; draft.identPostalCode = cipher.identity.decPostalCode || ''; draft.identCountry = cipher.identity.decCountry || ''; } if (cipher.sshKey) { draft.sshPrivateKey = cipher.sshKey.decPrivateKey || ''; draft.sshPublicKey = cipher.sshKey.decPublicKey || ''; draft.sshFingerprint = cipher.sshKey.decFingerprint || ''; } draft.customFields = (cipher.fields || []).map((field) => ({ type: parseFieldType(field.type), label: field.decName || '', value: field.decValue || '', })); return draft; } function maskSecret(value: string): string { if (!value) return ''; return '*'.repeat(Math.max(8, Math.min(24, value.length))); } function formatTotp(code: string): string { if (!code || code.length < 6) return code; return `${code.slice(0, 3)} ${code.slice(3, 6)}`; } function formatHistoryTime(value: string | null | undefined): string { if (!value) return t('txt_dash'); const date = new Date(value); if (!Number.isFinite(date.getTime())) return value; return date.toLocaleString(); } function parseAttachmentSizeBytes(attachment: CipherAttachment): number { const raw = attachment?.size; if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw; const parsed = Number(raw); if (Number.isFinite(parsed) && parsed >= 0) return parsed; return 0; } function formatAttachmentSize(attachment: CipherAttachment): string { const sizeName = String(attachment?.sizeName || '').trim(); if (sizeName) return sizeName; const bytes = parseAttachmentSizeBytes(attachment); if (bytes <= 0) return '0 B'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } function firstPasskeyCreationTime(cipher: Cipher | null): string | null { const credentials = cipher?.login?.fido2Credentials; if (!Array.isArray(credentials) || credentials.length === 0) return null; for (const credential of credentials) { const raw = String(credential?.creationDate || '').trim(); if (raw) return raw; } return null; } const TOTP_PERIOD_SECONDS = 30; const TOTP_RING_RADIUS = 14; const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; function VaultListIcon({ cipher }: { cipher: Cipher }) { const uri = firstCipherUri(cipher); const host = hostFromUri(uri); const [errored, setErrored] = useState(false); if (host && !errored) { return ( setErrored(true)} /> ); } return ( ); } function copyToClipboard(value: string): void { if (!value.trim()) return; void navigator.clipboard.writeText(value); } function openUri(raw: string): void { const value = raw.trim(); if (!value) return; const url = /^https?:\/\//i.test(value) ? value : `https://${value}`; window.open(url, '_blank', 'noopener'); } export default function VaultPage(props: VaultPageProps) { const [searchInput, setSearchInput] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [searchComposing, setSearchComposing] = useState(false); const [sidebarFilter, setSidebarFilter] = useState({ kind: 'all' }); const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedMap, setSelectedMap] = useState>({}); const [showPassword, setShowPassword] = useState(false); const [createMenuOpen, setCreateMenuOpen] = useState(false); const [isEditing, setIsEditing] = useState(false); const [isCreating, setIsCreating] = useState(false); const [draft, setDraft] = useState(null); const [fieldModalOpen, setFieldModalOpen] = useState(false); const [fieldType, setFieldType] = useState(0); const [fieldLabel, setFieldLabel] = useState(''); const [fieldValue, setFieldValue] = useState(''); const [localError, setLocalError] = useState(''); const [pendingDelete, setPendingDelete] = useState(null); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [moveOpen, setMoveOpen] = useState(false); const [moveFolderId, setMoveFolderId] = useState('__none__'); const [createFolderOpen, setCreateFolderOpen] = useState(false); const [newFolderName, setNewFolderName] = useState(''); const [pendingDeleteFolder, setPendingDeleteFolder] = useState(null); const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null); const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState>({}); const [attachmentQueue, setAttachmentQueue] = useState([]); const [removedAttachmentIds, setRemovedAttachmentIds] = useState>({}); const [busy, setBusy] = useState(false); const [repromptOpen, setRepromptOpen] = useState(false); const [repromptPassword, setRepromptPassword] = useState(''); const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState(null); const createMenuRef = useRef(null); const attachmentInputRef = useRef(null); const sshSeedTicketRef = useRef(0); const sshFingerprintTicketRef = useRef(0); useEffect(() => { const onQuickAdd = () => { startCreate(1); }; window.addEventListener('nodewarden:add-item', onQuickAdd); return () => window.removeEventListener('nodewarden:add-item', onQuickAdd); }, []); useEffect(() => { const onPointerDown = (event: Event) => { if (!createMenuOpen) return; const target = event.target as Node | null; if (createMenuRef.current && target && !createMenuRef.current.contains(target)) { setCreateMenuOpen(false); } }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setCreateMenuOpen(false); }; document.addEventListener('pointerdown', onPointerDown); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('pointerdown', onPointerDown); document.removeEventListener('keydown', onKeyDown); }; }, [createMenuOpen]); useEffect(() => { setRepromptApprovedCipherId(null); setRepromptPassword(''); setRepromptOpen(false); }, [selectedCipherId]); useEffect(() => { if (searchComposing) return; const timer = window.setTimeout(() => setSearchQuery(searchInput.trim().toLowerCase()), 90); return () => window.clearTimeout(timer); }, [searchInput, searchComposing]); useEffect(() => { if (!isEditing || !draft || draft.type !== 5) return; void recalculateSshFingerprint(draft.sshPublicKey); }, [isEditing, draft?.id, draft?.type]); const filteredCiphers = useMemo(() => { return props.ciphers.filter((cipher) => { const isDeleted = !!(cipher.deletedDate || (cipher as any).deletedAt); if (sidebarFilter.kind === 'trash') { if (!isDeleted) return false; } else { if (isDeleted) return false; if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false; if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false; if (sidebarFilter.kind === 'folder') { if (sidebarFilter.folderId === null) { if (cipher.folderId) return false; } else if (cipher.folderId !== sidebarFilter.folderId) { return false; } } } if (!searchQuery) return true; const name = (cipher.decName || '').toLowerCase(); const username = (cipher.login?.decUsername || '').toLowerCase(); const uri = firstCipherUri(cipher).toLowerCase(); return name.includes(searchQuery) || username.includes(searchQuery) || uri.includes(searchQuery); }); }, [props.ciphers, sidebarFilter, searchQuery]); useEffect(() => { if (isCreating) return; if (!filteredCiphers.length) { if (selectedCipherId) setSelectedCipherId(''); return; } if (!selectedCipherId || !filteredCiphers.some((x) => x.id === selectedCipherId)) { setSelectedCipherId(filteredCiphers[0].id); } }, [filteredCiphers, selectedCipherId, isCreating]); const selectedCipher = useMemo( () => props.ciphers.find((x) => x.id === selectedCipherId) || null, [props.ciphers, selectedCipherId] ); const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher); const selectedAttachments = useMemo( () => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []), [selectedCipher] ); const editExistingAttachments = useMemo( () => selectedAttachments.filter((attachment) => { const id = String(attachment?.id || '').trim(); return !!id; }), [selectedAttachments] ); const removedAttachmentCount = useMemo(() => Object.values(removedAttachmentIds).filter(Boolean).length, [removedAttachmentIds]); useEffect(() => { const raw = selectedCipher?.login?.decTotp || ''; if (!raw) { setTotpLive(null); return; } let stopped = false; let timer = 0; const tick = async () => { try { const now = await calcTotpNow(raw); if (!stopped) setTotpLive(now); } catch { if (!stopped) setTotpLive(null); } }; void tick(); timer = window.setInterval(() => void tick(), 1000); return () => { stopped = true; window.clearInterval(timer); }; }, [selectedCipher?.id, selectedCipher?.login?.decTotp]); const selectedCount = useMemo( () => Object.values(selectedMap).reduce((sum, v) => sum + (v ? 1 : 0), 0), [selectedMap] ); function folderName(id: string | null | undefined): string { if (!id) return t('txt_no_folder'); const folder = props.folders.find((x) => x.id === id); return folder?.decName || folder?.name || id; } function listSubtitle(cipher: Cipher): string { if (Number(cipher.type || 1) === 1) { return cipher.login?.decUsername || firstCipherUri(cipher) || ''; } return cipherTypeLabel(Number(cipher.type || 1)); } function startCreate(type: number): void { setDraft(createEmptyDraft(type)); setIsCreating(true); setIsEditing(true); setCreateMenuOpen(false); setSelectedCipherId(''); setShowPassword(false); setLocalError(''); setAttachmentQueue([]); setRemovedAttachmentIds({}); if (type === 5) void seedSshDefaults(); } function startEdit(): void { if (!selectedCipher) return; setDraft(draftFromCipher(selectedCipher)); setIsCreating(false); setIsEditing(true); setShowPassword(false); setLocalError(''); setAttachmentQueue([]); setRemovedAttachmentIds({}); } function cancelEdit(): void { setDraft(null); setIsEditing(false); setIsCreating(false); setLocalError(''); setAttachmentQueue([]); setRemovedAttachmentIds({}); } function updateDraft(patch: Partial): void { setDraft((prev) => (prev ? { ...prev, ...patch } : prev)); } async function seedSshDefaults(force = false): Promise { const ticket = ++sshSeedTicketRef.current; try { const generated = await generateDefaultSshKeyMaterial(); if (ticket !== sshSeedTicketRef.current) return; setDraft((prev) => { if (!prev || prev.type !== 5) return prev; if (!force && (prev.sshPrivateKey.trim() || prev.sshPublicKey.trim())) return prev; return { ...prev, sshPrivateKey: generated.privateKey, sshPublicKey: generated.publicKey, sshFingerprint: generated.fingerprint, }; }); } catch { // Browser may not support Ed25519 generation; user can still paste keys manually. } } async function recalculateSshFingerprint(publicKey: string): Promise { const ticket = ++sshFingerprintTicketRef.current; const fingerprint = await computeSshFingerprint(publicKey); if (ticket !== sshFingerprintTicketRef.current) return; setDraft((prev) => { if (!prev || prev.type !== 5) return prev; if (prev.sshPublicKey !== publicKey) return prev; if (prev.sshFingerprint === fingerprint) return prev; return { ...prev, sshFingerprint: fingerprint }; }); } function updateSshPublicKey(nextValue: string): void { setDraft((prev) => { if (!prev || prev.type !== 5) return prev; return { ...prev, sshPublicKey: nextValue }; }); void recalculateSshFingerprint(nextValue); } function updateDraftCustomFields(nextFields: VaultDraftField[]): void { setDraft((prev) => (prev ? { ...prev, customFields: nextFields } : prev)); } function patchDraftCustomField(index: number, patch: Partial): void { setDraft((prev) => { if (!prev) return prev; const next = [...prev.customFields]; next[index] = { ...next[index], ...patch }; return { ...prev, customFields: next }; }); } function updateDraftLoginUri(index: number, value: string): void { setDraft((prev) => { if (!prev) return prev; const next = [...prev.loginUris]; next[index] = value; return { ...prev, loginUris: next }; }); } function queueAttachmentFiles(list: FileList | null): void { if (!list || !list.length) return; const next = Array.from(list).filter((file) => file && file.size >= 0); if (!next.length) return; setAttachmentQueue((prev) => [...prev, ...next]); } function removeQueuedAttachment(index: number): void { setAttachmentQueue((prev) => prev.filter((_, i) => i !== index)); } function toggleExistingAttachmentRemoval(attachmentId: string): void { const id = String(attachmentId || '').trim(); if (!id) return; setRemovedAttachmentIds((prev) => { const next = { ...prev }; if (next[id]) delete next[id]; else next[id] = true; return next; }); } async function saveDraft(): Promise { if (!draft) return; let nextDraft = draft; if (nextDraft.type === 5) { const computedFingerprint = await computeSshFingerprint(nextDraft.sshPublicKey); if (computedFingerprint !== nextDraft.sshFingerprint) { nextDraft = { ...nextDraft, sshFingerprint: computedFingerprint }; setDraft(nextDraft); } } if (!nextDraft.name.trim()) { setLocalError(t('txt_item_name_is_required')); return; } setBusy(true); try { if (isCreating) { await props.onCreate(nextDraft, attachmentQueue); } else if (selectedCipher) { const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]); await props.onUpdate(selectedCipher, nextDraft, { addFiles: attachmentQueue, removeAttachmentIds, }); } setIsCreating(false); setIsEditing(false); setDraft(null); setLocalError(''); setAttachmentQueue([]); setRemovedAttachmentIds({}); } finally { setBusy(false); } } async function deleteSelected(): Promise { if (!pendingDelete) return; setBusy(true); try { await props.onDelete(pendingDelete); setPendingDelete(null); cancelEdit(); } finally { setBusy(false); } } async function confirmBulkDelete(): Promise { const ids = Object.entries(selectedMap) .filter(([, selected]) => selected) .map(([id]) => id); if (!ids.length) return; setBusy(true); try { await props.onBulkDelete(ids); setSelectedMap({}); setBulkDeleteOpen(false); } finally { setBusy(false); } } async function confirmBulkMove(): Promise { const ids = Object.entries(selectedMap) .filter(([, selected]) => selected) .map(([id]) => id); if (!ids.length) return; const folderId = moveFolderId === '__none__' ? null : moveFolderId; setBusy(true); try { await props.onBulkMove(ids, folderId); setSelectedMap({}); setMoveOpen(false); } finally { setBusy(false); } } async function syncVault(): Promise { setBusy(true); try { await props.onRefresh(); } finally { setBusy(false); } } async function verifyReprompt(): Promise { if (!selectedCipher) return; if (!repromptPassword) { props.onNotify('error', t('txt_master_password_is_required_2')); return; } setBusy(true); try { await props.onVerifyMasterPassword(props.emailForReprompt, repromptPassword); setRepromptApprovedCipherId(selectedCipher.id); setRepromptOpen(false); setRepromptPassword(''); } catch (error) { props.onNotify('error', error instanceof Error ? error.message : t('txt_unlock_failed')); } finally { setBusy(false); } } async function confirmCreateFolder(): Promise { if (!newFolderName.trim()) { props.onNotify('error', t('txt_folder_name_is_required')); return; } setBusy(true); try { await props.onCreateFolder(newFolderName); setCreateFolderOpen(false); setNewFolderName(''); } finally { setBusy(false); } } async function confirmDeleteFolder(): Promise { if (!pendingDeleteFolder) return; setBusy(true); try { await props.onDeleteFolder(pendingDeleteFolder.id); if (sidebarFilter.kind === 'folder' && sidebarFilter.folderId === pendingDeleteFolder.id) { setSidebarFilter({ kind: 'all' }); } setPendingDeleteFolder(null); } finally { setBusy(false); } } return ( <>
setSearchInput((e.currentTarget as HTMLInputElement).value)} onCompositionStart={() => setSearchComposing(true)} onCompositionEnd={(e) => { setSearchComposing(false); setSearchInput((e.currentTarget as HTMLInputElement).value); }} />
{createMenuOpen && (
{CREATE_TYPE_OPTIONS.map((option) => ( ))}
)}
{selectedCount > 0 && ( )} {selectedCount > 0 && ( )}
{filteredCiphers.map((cipher) => (
setSelectedMap((prev) => ({ ...prev, [cipher.id]: (e.currentTarget as HTMLInputElement).checked, })) } />
))} {!filteredCiphers.length &&
{t('txt_no_items')}
}
{isEditing && draft && ( <>

{isCreating ? `New ${cipherTypeLabel(draft.type)}` : `Edit ${cipherTypeLabel(draft.type)}`}

{draft.type === 1 && (

{t('txt_login_credentials')}

{t('txt_websites')}

{draft.loginUris.map((uri, index) => (
updateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} /> {draft.loginUris.length > 1 && ( )}
))}
)} {draft.type === 3 && (

{t('txt_card_details')}

)} {draft.type === 4 && (

{t('txt_identity_details')}

)} {draft.type === 5 && (

{t('txt_ssh_key')}