mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
36f398b728
- Implemented export formats for Bitwarden (JSON, encrypted JSON, ZIP) and NodeWarden (JSON). - Added support for attachments in ciphers and introduced new types for handling attachments. - Enhanced import formats to include Bitwarden ZIP and NodeWarden JSON. - Updated internationalization strings for attachment-related features. - Improved UI styles for attachment management and import summary display.
1752 lines
78 KiB
TypeScript
1752 lines
78 KiB
TypeScript
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<void>;
|
|
onCreate: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
|
onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
|
onDelete: (cipher: Cipher) => Promise<void>;
|
|
onBulkDelete: (ids: string[]) => Promise<void>;
|
|
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
|
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
|
onCreateFolder: (name: string) => Promise<void>;
|
|
onDeleteFolder: (folderId: string) => Promise<void>;
|
|
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
|
}
|
|
|
|
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 <Globe size={15} />;
|
|
if (type === 3) return <CreditCard size={15} />;
|
|
if (type === 4) return <ShieldUser size={15} />;
|
|
if (type === 2) return <StickyNote size={15} />;
|
|
if (type === 5) return <KeyRound size={15} />;
|
|
return <FileKey2 size={15} />;
|
|
}
|
|
|
|
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 <Globe size={18} />;
|
|
if (type === 3) return <CreditCard size={18} />;
|
|
if (type === 4) return <ShieldUser size={18} />;
|
|
if (type === 2) return <StickyNote size={18} />;
|
|
if (type === 5) return <KeyRound size={18} />;
|
|
return <FileKey2 size={18} />;
|
|
}
|
|
|
|
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 (
|
|
<img
|
|
className="list-icon"
|
|
src={`/icons/${host}/icon.png`}
|
|
alt=""
|
|
loading="lazy"
|
|
onError={() => setErrored(true)}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<span className="list-icon-fallback">
|
|
<TypeIcon type={Number(cipher.type || 1)} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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<SidebarFilter>({ kind: 'all' });
|
|
const [selectedCipherId, setSelectedCipherId] = useState('');
|
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [draft, setDraft] = useState<VaultDraft | null>(null);
|
|
const [fieldModalOpen, setFieldModalOpen] = useState(false);
|
|
const [fieldType, setFieldType] = useState<CustomFieldType>(0);
|
|
const [fieldLabel, setFieldLabel] = useState('');
|
|
const [fieldValue, setFieldValue] = useState('');
|
|
const [localError, setLocalError] = useState('');
|
|
const [pendingDelete, setPendingDelete] = useState<Cipher | null>(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<Folder | null>(null);
|
|
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
|
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
|
|
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
|
|
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
|
|
const [busy, setBusy] = useState(false);
|
|
const [repromptOpen, setRepromptOpen] = useState(false);
|
|
const [repromptPassword, setRepromptPassword] = useState('');
|
|
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
|
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
|
const attachmentInputRef = useRef<HTMLInputElement | null>(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<VaultDraft>): void {
|
|
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
|
}
|
|
|
|
async function seedSshDefaults(force = false): Promise<void> {
|
|
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<void> {
|
|
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<VaultDraftField>): 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<void> {
|
|
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<void> {
|
|
if (!pendingDelete) return;
|
|
setBusy(true);
|
|
try {
|
|
await props.onDelete(pendingDelete);
|
|
setPendingDelete(null);
|
|
cancelEdit();
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function confirmBulkDelete(): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
setBusy(true);
|
|
try {
|
|
await props.onRefresh();
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function verifyReprompt(): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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 (
|
|
<>
|
|
<div className="vault-grid">
|
|
<aside className="sidebar">
|
|
<div className="sidebar-block">
|
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'all' })}>
|
|
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
|
|
</button>
|
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'favorite' })}>
|
|
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
|
|
</button>
|
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'trash' })}>
|
|
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="sidebar-block">
|
|
<div className="sidebar-title">{t('txt_type')}</div>
|
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'login' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'login' })}>
|
|
<Globe size={14} className="tree-icon" /> <span className="tree-label">{t('txt_login')}</span>
|
|
</button>
|
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'card' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'card' })}>
|
|
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">{t('txt_card')}</span>
|
|
</button>
|
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'identity' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'identity' })}>
|
|
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">{t('txt_identity')}</span>
|
|
</button>
|
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'note' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'note' })}>
|
|
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">{t('txt_note')}</span>
|
|
</button>
|
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'type' && sidebarFilter.value === 'ssh' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'type', value: 'ssh' })}>
|
|
<KeyRound size={14} className="tree-icon" /> <span className="tree-label">{t('txt_ssh_key')}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="sidebar-block">
|
|
<div className="sidebar-title-row">
|
|
<div className="sidebar-title">{t('txt_folders')}</div>
|
|
<button type="button" className="folder-add-btn" onClick={() => setCreateFolderOpen(true)}>
|
|
<FolderPlus size={14} />
|
|
</button>
|
|
</div>
|
|
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'folder' && sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'folder', folderId: null })}>
|
|
<FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
|
|
</button>
|
|
{props.folders.map((folder) => (
|
|
<div key={folder.id} className="folder-row">
|
|
<button
|
|
type="button"
|
|
className={`tree-btn ${sidebarFilter.kind === 'folder' && sidebarFilter.folderId === folder.id ? 'active' : ''}`}
|
|
onClick={() => setSidebarFilter({ kind: 'folder', folderId: folder.id })}
|
|
>
|
|
<FolderIcon size={14} className="tree-icon" />
|
|
<span className="tree-label" title={folder.decName || folder.name || folder.id}>
|
|
{folder.decName || folder.name || folder.id}
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="folder-delete-btn"
|
|
title={t('txt_delete')}
|
|
aria-label={t('txt_delete')}
|
|
disabled={busy}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setPendingDeleteFolder(folder);
|
|
}}
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
|
|
<section className="list-col">
|
|
<div className="list-head">
|
|
<input
|
|
className="search-input"
|
|
placeholder={t('txt_search_your_secure_vault')}
|
|
value={searchInput}
|
|
onInput={(e) => setSearchInput((e.currentTarget as HTMLInputElement).value)}
|
|
onCompositionStart={() => setSearchComposing(true)}
|
|
onCompositionEnd={(e) => {
|
|
setSearchComposing(false);
|
|
setSearchInput((e.currentTarget as HTMLInputElement).value);
|
|
}}
|
|
/>
|
|
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void syncVault()}>
|
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
|
</button>
|
|
</div>
|
|
<div className="toolbar actions">
|
|
<button type="button" className="btn btn-danger small" disabled={!selectedCount || busy} onClick={() => setBulkDeleteOpen(true)}>
|
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_selected')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary small"
|
|
disabled={!filteredCiphers.length}
|
|
onClick={() => {
|
|
const map: Record<string, boolean> = {};
|
|
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
|
setSelectedMap(map);
|
|
}}
|
|
>
|
|
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
|
</button>
|
|
<div className="create-menu-wrap" ref={createMenuRef}>
|
|
<button type="button" className="btn btn-primary small" onClick={() => setCreateMenuOpen((x) => !x)}>
|
|
<Plus size={14} className="btn-icon" /> {t('txt_add')}
|
|
</button>
|
|
{createMenuOpen && (
|
|
<div className="create-menu">
|
|
{CREATE_TYPE_OPTIONS.map((option) => (
|
|
<button key={option.type} type="button" className="create-menu-item" onClick={() => startCreate(option.type)}>
|
|
<CreateTypeIcon type={option.type} />
|
|
<span>{option.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{selectedCount > 0 && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary small"
|
|
disabled={busy}
|
|
onClick={() => {
|
|
setMoveFolderId('__none__');
|
|
setMoveOpen(true);
|
|
}}
|
|
>
|
|
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
|
</button>
|
|
)}
|
|
{selectedCount > 0 && (
|
|
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
|
|
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="list-panel">
|
|
{filteredCiphers.map((cipher) => (
|
|
<div key={cipher.id} className={`list-item ${selectedCipherId === cipher.id ? 'active' : ''}`}>
|
|
<input
|
|
type="checkbox"
|
|
className="row-check"
|
|
checked={!!selectedMap[cipher.id]}
|
|
onInput={(e) =>
|
|
setSelectedMap((prev) => ({
|
|
...prev,
|
|
[cipher.id]: (e.currentTarget as HTMLInputElement).checked,
|
|
}))
|
|
}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="row-main"
|
|
onClick={() => {
|
|
if (isEditing || isCreating) {
|
|
cancelEdit();
|
|
}
|
|
setSelectedCipherId(cipher.id);
|
|
setRepromptApprovedCipherId(null);
|
|
}}
|
|
>
|
|
<div className="list-icon-wrap">
|
|
<VaultListIcon cipher={cipher} />
|
|
</div>
|
|
<div className="list-text">
|
|
<span className="list-title" title={cipher.decName || t('txt_no_name')}>{cipher.decName || t('txt_no_name')}</span>
|
|
<span className="list-sub" title={listSubtitle(cipher)}>{listSubtitle(cipher)}</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
))}
|
|
{!filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="detail-col">
|
|
{isEditing && draft && (
|
|
<>
|
|
<div className="card">
|
|
<div className="section-head">
|
|
<h3 className="detail-title">{isCreating ? `New ${cipherTypeLabel(draft.type)}` : `Edit ${cipherTypeLabel(draft.type)}`}</h3>
|
|
<button
|
|
type="button"
|
|
className={`btn btn-secondary small ${draft.favorite ? 'star-on' : ''}`}
|
|
onClick={() => updateDraft({ favorite: !draft.favorite })}
|
|
>
|
|
{draft.favorite ? <Star size={14} className="btn-icon" /> : <StarOff size={14} className="btn-icon" />}
|
|
{t('txt_favorite')}
|
|
</button>
|
|
</div>
|
|
<div className="field-grid">
|
|
<label className="field">
|
|
<span>{t('txt_type')}</span>
|
|
<select
|
|
className="input"
|
|
value={draft.type}
|
|
disabled={!isCreating}
|
|
onInput={(e) => {
|
|
const nextType = Number((e.currentTarget as HTMLSelectElement).value);
|
|
updateDraft({ type: nextType });
|
|
if (nextType === 5) void seedSshDefaults();
|
|
}}
|
|
>
|
|
{CREATE_TYPE_OPTIONS.map((option) => (
|
|
<option key={option.type} value={option.type}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('txt_folder')}</span>
|
|
<select
|
|
className="input"
|
|
value={draft.folderId}
|
|
onInput={(e) => updateDraft({ folderId: (e.currentTarget as HTMLSelectElement).value })}
|
|
>
|
|
<option value="">{t('txt_no_folder')}</option>
|
|
{props.folders.map((folder) => (
|
|
<option key={folder.id} value={folder.id}>
|
|
{folder.decName || folder.name || folder.id}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<label className="field">
|
|
<span>{t('txt_name')}</span>
|
|
<input className="input" value={draft.name} onInput={(e) => updateDraft({ name: (e.currentTarget as HTMLInputElement).value })} />
|
|
</label>
|
|
</div>
|
|
|
|
{draft.type === 1 && (
|
|
<div className="card">
|
|
<h4>{t('txt_login_credentials')}</h4>
|
|
<div className="field-grid">
|
|
<label className="field">
|
|
<span>{t('txt_username')}</span>
|
|
<input className="input" value={draft.loginUsername} onInput={(e) => updateDraft({ loginUsername: (e.currentTarget as HTMLInputElement).value })} />
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('txt_password')}</span>
|
|
<input className="input" value={draft.loginPassword} onInput={(e) => updateDraft({ loginPassword: (e.currentTarget as HTMLInputElement).value })} />
|
|
</label>
|
|
</div>
|
|
<label className="field">
|
|
<span>{t('txt_totp_secret')}</span>
|
|
<input className="input" value={draft.loginTotp} onInput={(e) => updateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} />
|
|
</label>
|
|
<div className="section-head">
|
|
<h4>{t('txt_websites')}</h4>
|
|
<button type="button" className="btn btn-secondary small" onClick={() => updateDraft({ loginUris: [...draft.loginUris, ''] })}>
|
|
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
|
|
</button>
|
|
</div>
|
|
{draft.loginUris.map((uri, index) => (
|
|
<div key={`uri-${index}`} className="website-row">
|
|
<input className="input" value={uri} onInput={(e) => updateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} />
|
|
{draft.loginUris.length > 1 && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary small"
|
|
onClick={() => updateDraft({ loginUris: draft.loginUris.filter((_, i) => i !== index) })}
|
|
>
|
|
<X size={14} className="btn-icon" />
|
|
{t('txt_remove')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{draft.type === 3 && (
|
|
<div className="card">
|
|
<h4>{t('txt_card_details')}</h4>
|
|
<div className="field-grid">
|
|
<label className="field">
|
|
<span>{t('txt_cardholder_name')}</span>
|
|
<input className="input" value={draft.cardholderName} onInput={(e) => updateDraft({ cardholderName: (e.currentTarget as HTMLInputElement).value })} />
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('txt_number')}</span>
|
|
<input className="input" value={draft.cardNumber} onInput={(e) => updateDraft({ cardNumber: (e.currentTarget as HTMLInputElement).value })} />
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('txt_brand')}</span>
|
|
<input className="input" value={draft.cardBrand} onInput={(e) => updateDraft({ cardBrand: (e.currentTarget as HTMLInputElement).value })} />
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('txt_security_code_cvv')}</span>
|
|
<input className="input" value={draft.cardCode} onInput={(e) => updateDraft({ cardCode: (e.currentTarget as HTMLInputElement).value })} />
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('txt_expiry_month')}</span>
|
|
<input className="input" value={draft.cardExpMonth} onInput={(e) => updateDraft({ cardExpMonth: (e.currentTarget as HTMLInputElement).value })} />
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('txt_expiry_year')}</span>
|
|
<input className="input" value={draft.cardExpYear} onInput={(e) => updateDraft({ cardExpYear: (e.currentTarget as HTMLInputElement).value })} />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{draft.type === 4 && (
|
|
<div className="card">
|
|
<h4>{t('txt_identity_details')}</h4>
|
|
<div className="field-grid">
|
|
<label className="field"><span>{t('txt_title')}</span><input className="input" value={draft.identTitle} onInput={(e) => updateDraft({ identTitle: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_first_name')}</span><input className="input" value={draft.identFirstName} onInput={(e) => updateDraft({ identFirstName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_middle_name')}</span><input className="input" value={draft.identMiddleName} onInput={(e) => updateDraft({ identMiddleName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_last_name')}</span><input className="input" value={draft.identLastName} onInput={(e) => updateDraft({ identLastName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_username')}</span><input className="input" value={draft.identUsername} onInput={(e) => updateDraft({ identUsername: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_company')}</span><input className="input" value={draft.identCompany} onInput={(e) => updateDraft({ identCompany: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_ssn')}</span><input className="input" value={draft.identSsn} onInput={(e) => updateDraft({ identSsn: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_passport_number')}</span><input className="input" value={draft.identPassportNumber} onInput={(e) => updateDraft({ identPassportNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_license_number')}</span><input className="input" value={draft.identLicenseNumber} onInput={(e) => updateDraft({ identLicenseNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_email')}</span><input className="input" value={draft.identEmail} onInput={(e) => updateDraft({ identEmail: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_phone')}</span><input className="input" value={draft.identPhone} onInput={(e) => updateDraft({ identPhone: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_address_1')}</span><input className="input" value={draft.identAddress1} onInput={(e) => updateDraft({ identAddress1: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_address_2')}</span><input className="input" value={draft.identAddress2} onInput={(e) => updateDraft({ identAddress2: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_address_3')}</span><input className="input" value={draft.identAddress3} onInput={(e) => updateDraft({ identAddress3: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_city_town')}</span><input className="input" value={draft.identCity} onInput={(e) => updateDraft({ identCity: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_state_province')}</span><input className="input" value={draft.identState} onInput={(e) => updateDraft({ identState: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_postal_code')}</span><input className="input" value={draft.identPostalCode} onInput={(e) => updateDraft({ identPostalCode: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
<label className="field"><span>{t('txt_country')}</span><input className="input" value={draft.identCountry} onInput={(e) => updateDraft({ identCountry: (e.currentTarget as HTMLInputElement).value })} /></label>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{draft.type === 5 && (
|
|
<div className="card">
|
|
<div className="section-head">
|
|
<h4>{t('txt_ssh_key')}</h4>
|
|
<button type="button" className="btn btn-secondary small" onClick={() => void seedSshDefaults(true)}>
|
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_regenerate')}
|
|
</button>
|
|
</div>
|
|
<label className="field">
|
|
<span>{t('txt_private_key')}</span>
|
|
<textarea className="input textarea" value={draft.sshPrivateKey} onInput={(e) => updateDraft({ sshPrivateKey: (e.currentTarget as HTMLTextAreaElement).value })} />
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('txt_public_key')}</span>
|
|
<textarea className="input textarea" value={draft.sshPublicKey} onInput={(e) => updateSshPublicKey((e.currentTarget as HTMLTextAreaElement).value)} />
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('txt_fingerprint')}</span>
|
|
<input className="input input-readonly" value={draft.sshFingerprint} readOnly />
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
<div className="card">
|
|
<div className="section-head attachment-head">
|
|
<h4>{t('txt_attachments')}</h4>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary small attachment-add-btn"
|
|
disabled={busy}
|
|
onClick={() => attachmentInputRef.current?.click()}
|
|
title={t('txt_upload_attachments')}
|
|
aria-label={t('txt_upload_attachments')}
|
|
>
|
|
<Plus size={14} className="btn-icon" />
|
|
</button>
|
|
</div>
|
|
{!isCreating && selectedCipher && editExistingAttachments.length > 0 && (
|
|
<div className="attachment-list">
|
|
{editExistingAttachments.map((attachment) => {
|
|
const attachmentId = String(attachment?.id || '').trim();
|
|
if (!attachmentId) return null;
|
|
const removed = !!removedAttachmentIds[attachmentId];
|
|
const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId;
|
|
return (
|
|
<div key={`edit-attachment-${attachmentId}`} className={`attachment-row ${removed ? 'is-removed' : ''}`}>
|
|
<div className="attachment-main">
|
|
<Paperclip size={14} />
|
|
<div className="attachment-text">
|
|
<strong className="value-ellipsis" title={fileName}>{fileName}</strong>
|
|
<span>{formatAttachmentSize(attachment)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="kv-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary small"
|
|
disabled={busy || removed}
|
|
onClick={() => void props.onDownloadAttachment(selectedCipher, attachmentId)}
|
|
>
|
|
<Download size={14} className="btn-icon" /> {t('txt_download')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary small"
|
|
disabled={busy}
|
|
onClick={() => toggleExistingAttachmentRemoval(attachmentId)}
|
|
>
|
|
<X size={14} className="btn-icon" />
|
|
{removed ? t('txt_cancel') : t('txt_remove')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{!!removedAttachmentCount && (
|
|
<div className="detail-sub">{t('txt_marked_for_removal_count', { count: removedAttachmentCount })}</div>
|
|
)}
|
|
<input
|
|
ref={attachmentInputRef}
|
|
type="file"
|
|
className="attachment-file-input"
|
|
multiple
|
|
disabled={busy}
|
|
onChange={(e) => {
|
|
const input = e.currentTarget as HTMLInputElement;
|
|
queueAttachmentFiles(input.files);
|
|
input.value = '';
|
|
}}
|
|
/>
|
|
{!!attachmentQueue.length && (
|
|
<div className="attachment-list">
|
|
<div className="attachment-queue-title">{t('txt_new_attachments')}</div>
|
|
{attachmentQueue.map((file, index) => (
|
|
<div key={`queued-attachment-${index}-${file.name}`} className="attachment-row">
|
|
<div className="attachment-main">
|
|
<Upload size={14} />
|
|
<div className="attachment-text">
|
|
<strong className="value-ellipsis" title={file.name}>{file.name}</strong>
|
|
<span>{formatAttachmentSize({ size: file.size })}</span>
|
|
</div>
|
|
</div>
|
|
<div className="kv-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary small"
|
|
disabled={busy}
|
|
onClick={() => removeQueuedAttachment(index)}
|
|
>
|
|
<X size={14} className="btn-icon" />
|
|
{t('txt_remove')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h4>{t('txt_additional_options')}</h4>
|
|
<label className="field">
|
|
<span>{t('txt_notes')}</span>
|
|
<textarea className="input textarea" value={draft.notes} onInput={(e) => updateDraft({ notes: (e.currentTarget as HTMLTextAreaElement).value })} />
|
|
</label>
|
|
<label className="check-line">
|
|
<input type="checkbox" checked={draft.reprompt} onInput={(e) => updateDraft({ reprompt: (e.currentTarget as HTMLInputElement).checked })} />
|
|
{t('txt_master_password_reprompt')}
|
|
</label>
|
|
<div className="section-head">
|
|
<h4>{t('txt_custom_fields')}</h4>
|
|
<button type="button" className="btn btn-secondary small" onClick={() => setFieldModalOpen(true)}>
|
|
<Plus size={14} className="btn-icon" /> {t('txt_add_field')}
|
|
</button>
|
|
</div>
|
|
{draft.customFields
|
|
.map((field, originalIndex) => ({ field, originalIndex }))
|
|
.filter((entry) => entry.field.type !== 3)
|
|
.map(({ field, originalIndex }) => (
|
|
<div key={`field-${originalIndex}`} className="uri-row">
|
|
<input
|
|
className="input"
|
|
value={field.label}
|
|
onInput={(e) => patchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })}
|
|
/>
|
|
{field.type === 2 ? (
|
|
<label className="check-line cf-check">
|
|
<input
|
|
type="checkbox"
|
|
checked={toBooleanFieldValue(field.value)}
|
|
onInput={(e) => patchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
|
|
/>
|
|
</label>
|
|
) : (
|
|
<input
|
|
className="input"
|
|
value={field.value}
|
|
onInput={(e) => patchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })}
|
|
/>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary small"
|
|
onClick={() => updateDraftCustomFields(draft.customFields.filter((_, i) => i !== originalIndex))}
|
|
>
|
|
<X size={14} className="btn-icon" />
|
|
{t('txt_remove')}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="detail-actions">
|
|
<div className="actions">
|
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void saveDraft()}>
|
|
<CheckCheck size={14} className="btn-icon" />
|
|
{t('txt_confirm')}
|
|
</button>
|
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={cancelEdit}>
|
|
<X size={14} className="btn-icon" />
|
|
{t('txt_cancel')}
|
|
</button>
|
|
</div>
|
|
{!isCreating && selectedCipher && (
|
|
<button type="button" className="btn btn-danger" disabled={busy} onClick={() => setPendingDelete(selectedCipher)}>
|
|
<Trash2 size={14} className="btn-icon" />
|
|
{t('txt_delete')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{localError && <div className="local-error">{localError}</div>}
|
|
</>
|
|
)}
|
|
|
|
{!isEditing && selectedCipher && (
|
|
<>
|
|
{Number(selectedCipher.reprompt || 0) === 1 && repromptApprovedCipherId !== selectedCipher.id && (
|
|
<div className="card">
|
|
<h4>{t('txt_master_password_reprompt_2')}</h4>
|
|
<div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div>
|
|
<div className="actions" style={{ marginTop: '10px' }}>
|
|
<button type="button" className="btn btn-primary" onClick={() => setRepromptOpen(true)}>
|
|
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{(Number(selectedCipher.reprompt || 0) !== 1 || repromptApprovedCipherId === selectedCipher.id) && (
|
|
<>
|
|
<div className="card">
|
|
<h3 className="detail-title">{selectedCipher.decName || t('txt_no_name')}</h3>
|
|
<div className="detail-sub">{folderName(selectedCipher.folderId)}</div>
|
|
</div>
|
|
|
|
{selectedCipher.login && (
|
|
<div className="card">
|
|
<h4>{t('txt_login_credentials')}</h4>
|
|
<div className="kv-row">
|
|
<span className="kv-label">{t('txt_username')}</span>
|
|
<div className="kv-main">
|
|
<strong className="value-ellipsis" title={selectedCipher.login.decUsername || ''}>{selectedCipher.login.decUsername || ''}</strong>
|
|
</div>
|
|
<div className="kv-actions">
|
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(selectedCipher.login?.decUsername || '')}>
|
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="kv-row">
|
|
<span className="kv-label">{t('txt_password')}</span>
|
|
<div className="kv-main">
|
|
<strong>{showPassword ? selectedCipher.login.decPassword || '' : maskSecret(selectedCipher.login.decPassword || '')}</strong>
|
|
</div>
|
|
<div className="kv-actions">
|
|
<button type="button" className="btn btn-secondary small" onClick={() => setShowPassword((v) => !v)}>
|
|
{showPassword ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
|
{showPassword ? t('txt_hide') : t('txt_reveal')}
|
|
</button>
|
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(selectedCipher.login?.decPassword || '')}>
|
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{!!selectedCipher.login.decTotp && (
|
|
<div className="kv-row">
|
|
<span className="kv-label">{t('txt_totp')}</span>
|
|
<div className="kv-main">
|
|
<div className="totp-inline">
|
|
<strong>{totpLive ? formatTotp(totpLive.code) : t('txt_text_3')}</strong>
|
|
<div
|
|
className="totp-timer"
|
|
title={t('txt_refresh_in_seconds_s', { seconds: totpLive ? totpLive.remain : 0 })}
|
|
aria-label={t('txt_refresh_in_seconds_s', { seconds: totpLive ? totpLive.remain : 0 })}
|
|
>
|
|
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
|
|
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
|
|
<circle
|
|
className="totp-ring-progress"
|
|
cx="18"
|
|
cy="18"
|
|
r={TOTP_RING_RADIUS}
|
|
style={{
|
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
|
strokeDashoffset: String(
|
|
TOTP_RING_CIRCUMFERENCE -
|
|
TOTP_RING_CIRCUMFERENCE *
|
|
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
|
),
|
|
}}
|
|
/>
|
|
</svg>
|
|
<span className="totp-timer-value">{totpLive ? totpLive.remain : 0}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="kv-actions">
|
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(totpLive?.code || '')}>
|
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!!passkeyCreatedAt && (
|
|
<div className="kv-row">
|
|
<span className="kv-label">{t('txt_passkey')}</span>
|
|
<div className="kv-main">
|
|
<strong>{t('txt_passkey_created_at_value', { value: formatHistoryTime(passkeyCreatedAt) })}</strong>
|
|
</div>
|
|
<div className="kv-actions" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{(selectedCipher.login?.uris || []).length > 0 && (
|
|
<div className="card">
|
|
<h4>{t('txt_autofill_options')}</h4>
|
|
{(selectedCipher.login?.uris || []).map((uri, index) => {
|
|
const value = uri.decUri || uri.uri || '';
|
|
if (!value.trim()) return null;
|
|
return (
|
|
<div key={`view-uri-${index}`} className="kv-row">
|
|
<span className="kv-label">{t('txt_website')}</span>
|
|
<div className="kv-main">
|
|
<strong className="value-ellipsis" title={value}>{value}</strong>
|
|
</div>
|
|
<div className="kv-actions">
|
|
<button type="button" className="btn btn-secondary small" onClick={() => openUri(value)}>
|
|
<ExternalLink size={14} className="btn-icon" /> {t('txt_open')}
|
|
</button>
|
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(value)}>
|
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{selectedCipher.card && (
|
|
<div className="card">
|
|
<h4>{t('txt_card_details')}</h4>
|
|
<div className="kv-line"><span>{t('txt_cardholder_name')}</span><strong>{selectedCipher.card.decCardholderName || ''}</strong></div>
|
|
<div className="kv-line"><span>{t('txt_number')}</span><strong>{selectedCipher.card.decNumber || ''}</strong></div>
|
|
<div className="kv-line"><span>{t('txt_brand')}</span><strong>{selectedCipher.card.decBrand || ''}</strong></div>
|
|
<div className="kv-line"><span>{t('txt_expiry')}</span><strong>{`${selectedCipher.card.decExpMonth || ''}/${selectedCipher.card.decExpYear || ''}`}</strong></div>
|
|
<div className="kv-line"><span>{t('txt_security_code')}</span><strong>{selectedCipher.card.decCode || ''}</strong></div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedCipher.identity && (
|
|
<div className="card">
|
|
<h4>{t('txt_identity_details')}</h4>
|
|
<div className="kv-line"><span>{t('txt_name')}</span><strong>{`${selectedCipher.identity.decFirstName || ''} ${selectedCipher.identity.decLastName || ''}`.trim()}</strong></div>
|
|
<div className="kv-line"><span>{t('txt_username')}</span><strong>{selectedCipher.identity.decUsername || ''}</strong></div>
|
|
<div className="kv-line"><span>{t('txt_email')}</span><strong>{selectedCipher.identity.decEmail || ''}</strong></div>
|
|
<div className="kv-line"><span>{t('txt_phone')}</span><strong>{selectedCipher.identity.decPhone || ''}</strong></div>
|
|
<div className="kv-line"><span>{t('txt_company')}</span><strong>{selectedCipher.identity.decCompany || ''}</strong></div>
|
|
<div className="kv-line"><span>{t('txt_address')}</span><strong>{[selectedCipher.identity.decAddress1, selectedCipher.identity.decAddress2, selectedCipher.identity.decAddress3, selectedCipher.identity.decCity, selectedCipher.identity.decState, selectedCipher.identity.decPostalCode, selectedCipher.identity.decCountry].filter(Boolean).join(', ')}</strong></div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedCipher.sshKey && (
|
|
<div className="card">
|
|
<h4>{t('txt_ssh_key')}</h4>
|
|
<div className="kv-line"><span>{t('txt_private_key')}</span><strong>{maskSecret(selectedCipher.sshKey.decPrivateKey || '')}</strong></div>
|
|
<div className="kv-line"><span>{t('txt_public_key')}</span><strong>{selectedCipher.sshKey.decPublicKey || ''}</strong></div>
|
|
<div className="kv-line"><span>{t('txt_fingerprint')}</span><strong>{selectedCipher.sshKey.decFingerprint || ''}</strong></div>
|
|
</div>
|
|
)}
|
|
|
|
{!!(selectedCipher.decNotes || '').trim() && (
|
|
<div className="card">
|
|
<h4>{t('txt_notes')}</h4>
|
|
<div className="notes">{selectedCipher.decNotes || ''}</div>
|
|
</div>
|
|
)}
|
|
|
|
{(selectedCipher.fields || []).some((x) => parseFieldType(x.type) !== 3) && (
|
|
<div className="card">
|
|
<h4>{t('txt_custom_fields')}</h4>
|
|
{(selectedCipher.fields || [])
|
|
.filter((x) => parseFieldType(x.type) !== 3)
|
|
.map((field, index) => {
|
|
const fieldType = parseFieldType(field.type);
|
|
const fieldName = field.decName || t('txt_field');
|
|
const rawValue = field.decValue || '';
|
|
const isHiddenVisible = !!hiddenFieldVisibleMap[index];
|
|
if (fieldType === 2) {
|
|
const checked = toBooleanFieldValue(rawValue);
|
|
return (
|
|
<div key={`view-field-${index}`} className="kv-row custom-field-row">
|
|
<span className="kv-label" title={fieldName}>{fieldName}</span>
|
|
<div className="kv-main boolean-main">
|
|
<label className="check-line cf-check view">
|
|
<input type="checkbox" checked={checked} disabled />
|
|
</label>
|
|
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
|
|
{checked ? t('txt_checked') : t('txt_unchecked')}
|
|
</span>
|
|
</div>
|
|
<div className="kv-actions" />
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div key={`view-field-${index}`} className="kv-row custom-field-row">
|
|
<span className="kv-label" title={fieldName}>{fieldName}</span>
|
|
<div className="kv-main">
|
|
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
|
|
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
|
|
</strong>
|
|
</div>
|
|
<div className="kv-actions">
|
|
{fieldType === 1 && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary small"
|
|
onClick={() => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
|
|
>
|
|
{isHiddenVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
|
{isHiddenVisible ? t('txt_hide') : t('txt_reveal')}
|
|
</button>
|
|
)}
|
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
|
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{selectedAttachments.some((attachment) => String(attachment?.id || '').trim()) && (
|
|
<div className="card">
|
|
<h4>{t('txt_attachments')}</h4>
|
|
<div className="attachment-list">
|
|
{selectedAttachments.map((attachment) => {
|
|
const attachmentId = String(attachment?.id || '').trim();
|
|
if (!attachmentId) return null;
|
|
const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId;
|
|
return (
|
|
<div key={`view-attachment-${attachmentId}`} className="attachment-row">
|
|
<div className="attachment-main">
|
|
<Paperclip size={14} />
|
|
<div className="attachment-text">
|
|
<strong className="value-ellipsis" title={fileName}>{fileName}</strong>
|
|
<span>{formatAttachmentSize(attachment)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="kv-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary small"
|
|
onClick={() => void props.onDownloadAttachment(selectedCipher, attachmentId)}
|
|
>
|
|
<Download size={14} className="btn-icon" /> {t('txt_download')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{(selectedCipher.creationDate || selectedCipher.revisionDate) && (
|
|
<div className="card">
|
|
<h4>{t('txt_item_history')}</h4>
|
|
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(selectedCipher.revisionDate) })}</div>
|
|
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(selectedCipher.creationDate) })}</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="detail-actions">
|
|
<div className="actions">
|
|
<button type="button" className="btn btn-secondary" onClick={startEdit}>
|
|
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
|
|
</button>
|
|
</div>
|
|
<button type="button" className="btn btn-danger" onClick={() => setPendingDelete(selectedCipher)}>
|
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{!isEditing && !selectedCipher && <div className="empty card">{t('txt_select_an_item')}</div>}
|
|
</section>
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
open={fieldModalOpen}
|
|
title={t('txt_add_field')}
|
|
message={t('txt_configure_custom_field_values')}
|
|
confirmText={t('txt_add')}
|
|
cancelText={t('txt_cancel')}
|
|
onConfirm={() => {
|
|
if (!draft) return;
|
|
if (!fieldLabel.trim()) {
|
|
setLocalError(t('txt_field_label_is_required'));
|
|
return;
|
|
}
|
|
updateDraftCustomFields([
|
|
...draft.customFields,
|
|
{
|
|
type: fieldType,
|
|
label: fieldLabel.trim(),
|
|
value: fieldType === 2 ? (toBooleanFieldValue(fieldValue) ? 'true' : 'false') : fieldValue,
|
|
},
|
|
]);
|
|
setFieldModalOpen(false);
|
|
setFieldType(0);
|
|
setFieldLabel('');
|
|
setFieldValue('');
|
|
setLocalError('');
|
|
}}
|
|
onCancel={() => {
|
|
setFieldModalOpen(false);
|
|
setFieldType(0);
|
|
setFieldLabel('');
|
|
setFieldValue('');
|
|
}}
|
|
>
|
|
<label className="field">
|
|
<span>{t('txt_field_type')}</span>
|
|
<select className="input" value={fieldType} onInput={(e) => setFieldType(Number((e.currentTarget as HTMLSelectElement).value) as CustomFieldType)}>
|
|
{FIELD_TYPE_OPTIONS.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="field">
|
|
<span>{t('txt_field_label')}</span>
|
|
<input className="input" value={fieldLabel} onInput={(e) => setFieldLabel((e.currentTarget as HTMLInputElement).value)} />
|
|
</label>
|
|
{fieldType === 2 ? (
|
|
<label className="check-line">
|
|
<input
|
|
type="checkbox"
|
|
checked={toBooleanFieldValue(fieldValue)}
|
|
onInput={(e) => setFieldValue((e.currentTarget as HTMLInputElement).checked ? 'true' : 'false')}
|
|
/>
|
|
{t('txt_enabled')}
|
|
</label>
|
|
) : (
|
|
<label className="field">
|
|
<span>{t('txt_field_value')}</span>
|
|
<input className="input" value={fieldValue} onInput={(e) => setFieldValue((e.currentTarget as HTMLInputElement).value)} />
|
|
</label>
|
|
)}
|
|
</ConfirmDialog>
|
|
|
|
<ConfirmDialog
|
|
open={!!pendingDelete}
|
|
title={t('txt_delete_item')}
|
|
message={t('txt_are_you_sure_you_want_to_delete_this_item')}
|
|
danger
|
|
onConfirm={() => void deleteSelected()}
|
|
onCancel={() => setPendingDelete(null)}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={bulkDeleteOpen}
|
|
title={t('txt_delete_selected_items')}
|
|
message={t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: selectedCount })}
|
|
danger
|
|
onConfirm={() => void confirmBulkDelete()}
|
|
onCancel={() => setBulkDeleteOpen(false)}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={moveOpen}
|
|
title={t('txt_move_selected_items')}
|
|
message={t('txt_choose_destination_folder')}
|
|
confirmText={t('txt_move')}
|
|
cancelText={t('txt_cancel')}
|
|
onConfirm={() => void confirmBulkMove()}
|
|
onCancel={() => setMoveOpen(false)}
|
|
>
|
|
<label className="field">
|
|
<span>{t('txt_folder')}</span>
|
|
<select className="input" value={moveFolderId} onInput={(e) => setMoveFolderId((e.currentTarget as HTMLSelectElement).value)}>
|
|
<option value="__none__">{t('txt_no_folder')}</option>
|
|
{props.folders.map((folder) => (
|
|
<option key={folder.id} value={folder.id}>
|
|
{folder.decName || folder.name || folder.id}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</ConfirmDialog>
|
|
|
|
<ConfirmDialog
|
|
open={createFolderOpen}
|
|
title={t('txt_create_folder')}
|
|
message={t('txt_enter_a_folder_name')}
|
|
confirmText={t('txt_create')}
|
|
cancelText={t('txt_cancel')}
|
|
onConfirm={() => void confirmCreateFolder()}
|
|
onCancel={() => {
|
|
setCreateFolderOpen(false);
|
|
setNewFolderName('');
|
|
}}
|
|
>
|
|
<label className="field">
|
|
<span>{t('txt_folder_name')}</span>
|
|
<input className="input" value={newFolderName} onInput={(e) => setNewFolderName((e.currentTarget as HTMLInputElement).value)} />
|
|
</label>
|
|
</ConfirmDialog>
|
|
|
|
<ConfirmDialog
|
|
open={!!pendingDeleteFolder}
|
|
title={`${t('txt_delete')} ${t('txt_folder')}`}
|
|
message={`Delete folder "${pendingDeleteFolder?.decName || pendingDeleteFolder?.name || pendingDeleteFolder?.id || ''}"? Items inside will move to ${t('txt_no_folder')}.`}
|
|
confirmText={t('txt_delete')}
|
|
cancelText={t('txt_cancel')}
|
|
danger
|
|
onConfirm={() => void confirmDeleteFolder()}
|
|
onCancel={() => setPendingDeleteFolder(null)}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={repromptOpen}
|
|
title={t('txt_unlock_item')}
|
|
message={t('txt_enter_master_password_to_view_this_item')}
|
|
confirmText={t('txt_unlock')}
|
|
cancelText={t('txt_cancel')}
|
|
showIcon={false}
|
|
onConfirm={() => void verifyReprompt()}
|
|
onCancel={() => {
|
|
setRepromptOpen(false);
|
|
setRepromptPassword('');
|
|
}}
|
|
>
|
|
<label className="field">
|
|
<span>{t('txt_master_password')}</span>
|
|
<input className="input" type="password" value={repromptPassword} onInput={(e) => setRepromptPassword((e.currentTarget as HTMLInputElement).value)} />
|
|
</label>
|
|
</ConfirmDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
|
|
|
|
|
|
|