mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
refactor: enhance manual chunking in Vite config for better code splitting
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import { Link } from 'wouter';
|
||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Profile } from '@/lib/types';
|
||||
|
||||
interface AppAuthenticatedShellProps {
|
||||
profile: Profile | null;
|
||||
location: string;
|
||||
mobilePrimaryRoute: string;
|
||||
currentPageTitle: string;
|
||||
showSidebarToggle: boolean;
|
||||
sidebarToggleTitle: string;
|
||||
settingsAccountRoute: string;
|
||||
importRoute: string;
|
||||
isImportRoute: boolean;
|
||||
onLock: () => void;
|
||||
onLogout: () => void;
|
||||
mainRoutesProps: AppMainRoutesProps;
|
||||
}
|
||||
|
||||
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||
return (
|
||||
<div className="app-page">
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
|
||||
<span className="brand-name">NodeWarden</span>
|
||||
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<div className="user-chip">
|
||||
<ShieldUser size={16} />
|
||||
<span>{props.profile?.email}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onLock}>
|
||||
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
||||
</button>
|
||||
{props.showSidebarToggle && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small mobile-sidebar-toggle"
|
||||
aria-label={props.sidebarToggleTitle}
|
||||
title={props.sidebarToggleTitle}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))}
|
||||
>
|
||||
<FolderIcon size={16} className="btn-icon" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={props.onLock}>
|
||||
<Lock size={14} className="btn-icon" />
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onLogout}>
|
||||
<LogOut size={14} className="btn-icon" /> {t('txt_sign_out')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="app-main">
|
||||
<aside className="app-side">
|
||||
<Link href="/vault" className={`side-link ${props.location === '/vault' ? 'active' : ''}`}>
|
||||
<KeyRound size={16} />
|
||||
<span>{t('nav_my_vault')}</span>
|
||||
</Link>
|
||||
<Link href="/vault/totp" className={`side-link ${props.location === '/vault/totp' ? 'active' : ''}`}>
|
||||
<Clock3 size={16} />
|
||||
<span>{t('txt_verification_code')}</span>
|
||||
</Link>
|
||||
<Link href="/sends" className={`side-link ${props.location === '/sends' ? 'active' : ''}`}>
|
||||
<SendIcon size={16} />
|
||||
<span>{t('nav_sends')}</span>
|
||||
</Link>
|
||||
{props.profile?.role === 'admin' && (
|
||||
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
|
||||
<ShieldUser size={16} />
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={props.settingsAccountRoute} className={`side-link ${props.location === props.settingsAccountRoute ? 'active' : ''}`}>
|
||||
<SettingsIcon size={16} />
|
||||
<span>{t('nav_account_settings')}</span>
|
||||
</Link>
|
||||
<Link href="/security/devices" className={`side-link ${props.location === '/security/devices' ? 'active' : ''}`}>
|
||||
<Shield size={16} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
{props.profile?.role === 'admin' && (
|
||||
<Link href="/help" className={`side-link ${props.location === '/help' ? 'active' : ''}`}>
|
||||
<Cloud size={16} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={props.importRoute} className={`side-link ${props.isImportRoute ? 'active' : ''}`}>
|
||||
<ArrowUpDown size={14} />
|
||||
<span>{t('nav_import_export')}</span>
|
||||
</Link>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<AppMainRoutes {...props.mainRoutesProps} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<nav className="mobile-tabbar" aria-label={t('txt_menu')}>
|
||||
<Link href="/vault" className={`mobile-tab ${props.mobilePrimaryRoute === '/vault' ? 'active' : ''}`}>
|
||||
<KeyRound size={18} />
|
||||
<span>{t('nav_my_vault')}</span>
|
||||
</Link>
|
||||
<Link href="/vault/totp" className={`mobile-tab ${props.mobilePrimaryRoute === '/vault/totp' ? 'active' : ''}`}>
|
||||
<Clock3 size={18} />
|
||||
<span>{t('txt_verification_code')}</span>
|
||||
</Link>
|
||||
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
||||
<SendIcon size={18} />
|
||||
<span>{t('nav_sends')}</span>
|
||||
</Link>
|
||||
<Link href="/settings" className={`mobile-tab ${props.mobilePrimaryRoute === '/settings' ? 'active' : ''}`}>
|
||||
<SettingsIcon size={18} />
|
||||
<span>{t('txt_settings')}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import ToastHost from '@/components/ToastHost';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { ToastMessage } from '@/lib/types';
|
||||
|
||||
export interface AppConfirmState {
|
||||
title: string;
|
||||
message: string;
|
||||
danger?: boolean;
|
||||
showIcon?: boolean;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
interface AppGlobalOverlaysProps {
|
||||
toasts: ToastMessage[];
|
||||
onCloseToast: (id: string) => void;
|
||||
confirm: AppConfirmState | null;
|
||||
onCancelConfirm: () => void;
|
||||
pendingTotpOpen: boolean;
|
||||
totpCode: string;
|
||||
rememberDevice: boolean;
|
||||
onTotpCodeChange: (value: string) => void;
|
||||
onRememberDeviceChange: (checked: boolean) => void;
|
||||
onConfirmTotp: () => void;
|
||||
onCancelTotp: () => void;
|
||||
onUseRecoveryCode: () => void;
|
||||
disableTotpOpen: boolean;
|
||||
disableTotpPassword: string;
|
||||
onDisableTotpPasswordChange: (value: string) => void;
|
||||
onConfirmDisableTotp: () => void;
|
||||
onCancelDisableTotp: () => void;
|
||||
}
|
||||
|
||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={!!props.confirm}
|
||||
title={props.confirm?.title || ''}
|
||||
message={props.confirm?.message || ''}
|
||||
danger={props.confirm?.danger}
|
||||
showIcon={props.confirm?.showIcon}
|
||||
onConfirm={() => props.confirm?.onConfirm()}
|
||||
onCancel={props.onCancelConfirm}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.pendingTotpOpen}
|
||||
title={t('txt_two_step_verification')}
|
||||
message={t('txt_password_is_already_verified')}
|
||||
confirmText={t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
onConfirm={props.onConfirmTotp}
|
||||
onCancel={props.onCancelTotp}
|
||||
afterActions={(
|
||||
<div className="dialog-extra">
|
||||
<div className="dialog-divider" />
|
||||
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onUseRecoveryCode}>
|
||||
{t('txt_use_recovery_code')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_totp_code')}</span>
|
||||
<input className="input" value={props.totpCode} onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label className="check-line" style={{ marginBottom: 0 }}>
|
||||
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
||||
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.disableTotpOpen}
|
||||
title={t('txt_disable_totp')}
|
||||
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
||||
confirmText={t('txt_disable_totp')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
showIcon={false}
|
||||
onConfirm={props.onConfirmDisableTotp}
|
||||
onCancel={props.onCancelDisableTotp}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input className="input" type="password" value={props.disableTotpPassword} onInput={(e) => props.onDisableTotpPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ToastHost toasts={props.toasts} onClose={props.onCloseToast} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,15 @@ import { lazy, Suspense } from 'preact/compat';
|
||||
import { Link, Route, Switch } from 'wouter';
|
||||
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||
import SendsPage from '@/components/SendsPage';
|
||||
import TotpCodesPage from '@/components/TotpCodesPage';
|
||||
import VaultPage from '@/components/VaultPage';
|
||||
import type { AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
||||
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
||||
@@ -21,7 +21,7 @@ function RouteContentFallback() {
|
||||
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
|
||||
}
|
||||
|
||||
interface AppMainRoutesProps {
|
||||
export interface AppMainRoutesProps {
|
||||
profile: Profile | null;
|
||||
session: SessionState | null;
|
||||
mobileLayout: boolean;
|
||||
@@ -128,41 +128,47 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/sends">
|
||||
<SendsPage
|
||||
sends={props.decryptedSends}
|
||||
loading={props.sendsLoading}
|
||||
onRefresh={props.onRefreshVault}
|
||||
onCreate={props.onCreateSend}
|
||||
onUpdate={props.onUpdateSend}
|
||||
onDelete={props.onDeleteSend}
|
||||
onBulkDelete={props.onBulkDeleteSends}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SendsPage
|
||||
sends={props.decryptedSends}
|
||||
loading={props.sendsLoading}
|
||||
onRefresh={props.onRefreshVault}
|
||||
onCreate={props.onCreateSend}
|
||||
onUpdate={props.onUpdateSend}
|
||||
onDelete={props.onDeleteSend}
|
||||
onBulkDelete={props.onBulkDeleteSends}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path="/vault/totp">
|
||||
<TotpCodesPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} onNotify={props.onNotify} />
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<TotpCodesPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} onNotify={props.onNotify} />
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path="/vault">
|
||||
<VaultPage
|
||||
ciphers={props.decryptedCiphers}
|
||||
folders={props.decryptedFolders}
|
||||
loading={props.ciphersLoading || props.foldersLoading}
|
||||
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
||||
onRefresh={props.onRefreshVault}
|
||||
onCreate={props.onCreateVaultItem}
|
||||
onUpdate={props.onUpdateVaultItem}
|
||||
onDelete={props.onDeleteVaultItem}
|
||||
onBulkDelete={props.onBulkDeleteVaultItems}
|
||||
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
||||
onBulkRestore={props.onBulkRestoreVaultItems}
|
||||
onBulkMove={props.onBulkMoveVaultItems}
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onNotify={props.onNotify}
|
||||
onCreateFolder={props.onCreateFolder}
|
||||
onDeleteFolder={props.onDeleteFolder}
|
||||
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||
/>
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<VaultPage
|
||||
ciphers={props.decryptedCiphers}
|
||||
folders={props.decryptedFolders}
|
||||
loading={props.ciphersLoading || props.foldersLoading}
|
||||
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
||||
onRefresh={props.onRefreshVault}
|
||||
onCreate={props.onCreateVaultItem}
|
||||
onUpdate={props.onUpdateVaultItem}
|
||||
onDelete={props.onDeleteVaultItem}
|
||||
onBulkDelete={props.onBulkDeleteVaultItems}
|
||||
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
||||
onBulkRestore={props.onBulkRestoreVaultItems}
|
||||
onBulkMove={props.onBulkMoveVaultItems}
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onNotify={props.onNotify}
|
||||
onCreateFolder={props.onCreateFolder}
|
||||
onDeleteFolder={props.onDeleteFolder}
|
||||
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||
/>
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path={props.settingsAccountRoute}>
|
||||
{props.profile && (
|
||||
|
||||
@@ -13,14 +13,14 @@ import {
|
||||
type ExportRequest,
|
||||
} from '@/lib/export-formats';
|
||||
import {
|
||||
getFileAcceptBySource,
|
||||
IMPORT_SOURCES,
|
||||
type BitwardenJsonInput,
|
||||
type ImportSourceId,
|
||||
normalizeBitwardenEncryptedAccountImport,
|
||||
normalizeBitwardenImport,
|
||||
parseImportPayloadBySource,
|
||||
} from '@/lib/import-formats';
|
||||
import { getFileAcceptBySource, IMPORT_SOURCES, type ImportSourceId } from '@/lib/import-format-sources';
|
||||
import {
|
||||
type BitwardenJsonInput,
|
||||
normalizeBitwardenEncryptedAccountImport,
|
||||
normalizeBitwardenImport,
|
||||
} from '@/lib/import-formats-bitwarden';
|
||||
import { base64ToBytes, decryptStr, hkdfExpand, pbkdf2 } from '@/lib/crypto';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Folder } from '@/lib/types';
|
||||
|
||||
+191
-1379
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
import { Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, Trash2 } from 'lucide-preact';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
TOTP_PERIOD_SECONDS,
|
||||
TOTP_RING_CIRCUMFERENCE,
|
||||
copyToClipboard,
|
||||
formatAttachmentSize,
|
||||
formatHistoryTime,
|
||||
formatTotp,
|
||||
maskSecret,
|
||||
openUri,
|
||||
parseFieldType,
|
||||
toBooleanFieldValue,
|
||||
} from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface VaultDetailViewProps {
|
||||
selectedCipher: Cipher;
|
||||
repromptApprovedCipherId: string | null;
|
||||
showPassword: boolean;
|
||||
totpLive: { code: string; remain: number } | null;
|
||||
passkeyCreatedAt: string | null;
|
||||
hiddenFieldVisibleMap: Record<number, boolean>;
|
||||
folderName: (id: string | null | undefined) => string;
|
||||
onOpenReprompt: () => void;
|
||||
onToggleShowPassword: () => void;
|
||||
onToggleHiddenField: (index: number) => void;
|
||||
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onDelete: (cipher: Cipher) => void;
|
||||
}
|
||||
|
||||
export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{Number(props.selectedCipher.reprompt || 0) === 1 && props.repromptApprovedCipherId !== props.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={props.onOpenReprompt}>
|
||||
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(Number(props.selectedCipher.reprompt || 0) !== 1 || props.repromptApprovedCipherId === props.selectedCipher.id) && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
|
||||
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
|
||||
</div>
|
||||
|
||||
{props.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={props.selectedCipher.login.decUsername || ''}>{props.selectedCipher.login.decUsername || ''}</strong>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.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>{props.showPassword ? props.selectedCipher.login.decPassword || '' : maskSecret(props.selectedCipher.login.decPassword || '')}</strong>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onToggleShowPassword}>
|
||||
{props.showPassword ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
||||
{props.showPassword ? t('txt_hide') : t('txt_reveal')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}>
|
||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!!props.selectedCipher.login.decTotp && (
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_totp')}</span>
|
||||
<div className="kv-main">
|
||||
<div className="totp-inline">
|
||||
<strong>{props.totpLive ? formatTotp(props.totpLive.code) : t('txt_text_3')}</strong>
|
||||
<div
|
||||
className="totp-timer"
|
||||
title={t('txt_refresh_in_seconds_s', { seconds: props.totpLive ? props.totpLive.remain : 0 })}
|
||||
aria-label={t('txt_refresh_in_seconds_s', { seconds: props.totpLive ? props.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="15.9155" />
|
||||
<circle
|
||||
className="totp-ring-progress"
|
||||
cx="18"
|
||||
cy="18"
|
||||
r="15.9155"
|
||||
style={{
|
||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||
strokeDashoffset: String(
|
||||
TOTP_RING_CIRCUMFERENCE -
|
||||
TOTP_RING_CIRCUMFERENCE *
|
||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<span className="totp-timer-value">{props.totpLive ? props.totpLive.remain : 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.totpLive?.code || '')}>
|
||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!props.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(props.passkeyCreatedAt) })}</strong>
|
||||
</div>
|
||||
<div className="kv-actions" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(props.selectedCipher.login?.uris || []).length > 0 && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_autofill_options')}</h4>
|
||||
{(props.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>
|
||||
)}
|
||||
|
||||
{props.selectedCipher.card && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_card_details')}</h4>
|
||||
<div className="kv-line"><span>{t('txt_cardholder_name')}</span><strong>{props.selectedCipher.card.decCardholderName || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_number')}</span><strong>{props.selectedCipher.card.decNumber || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_brand')}</span><strong>{props.selectedCipher.card.decBrand || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_expiry')}</span><strong>{`${props.selectedCipher.card.decExpMonth || ''}/${props.selectedCipher.card.decExpYear || ''}`}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_security_code')}</span><strong>{props.selectedCipher.card.decCode || ''}</strong></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.selectedCipher.identity && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_identity_details')}</h4>
|
||||
<div className="kv-line"><span>{t('txt_name')}</span><strong>{`${props.selectedCipher.identity.decFirstName || ''} ${props.selectedCipher.identity.decLastName || ''}`.trim()}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_username')}</span><strong>{props.selectedCipher.identity.decUsername || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_email')}</span><strong>{props.selectedCipher.identity.decEmail || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_phone')}</span><strong>{props.selectedCipher.identity.decPhone || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_company')}</span><strong>{props.selectedCipher.identity.decCompany || ''}</strong></div>
|
||||
<div className="kv-line"><span>{t('txt_address')}</span><strong>{[props.selectedCipher.identity.decAddress1, props.selectedCipher.identity.decAddress2, props.selectedCipher.identity.decAddress3, props.selectedCipher.identity.decCity, props.selectedCipher.identity.decState, props.selectedCipher.identity.decPostalCode, props.selectedCipher.identity.decCountry].filter(Boolean).join(', ')}</strong></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.selectedCipher.sshKey && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_ssh_key')}</h4>
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_private_key')}</span>
|
||||
<div className="kv-main">
|
||||
<strong className="value-ellipsis" title={maskSecret(props.selectedCipher.sshKey.decPrivateKey || '')}>
|
||||
{maskSecret(props.selectedCipher.sshKey.decPrivateKey || '')}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="kv-actions" />
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_public_key')}</span>
|
||||
<div className="kv-main">
|
||||
<strong className="value-ellipsis" title={props.selectedCipher.sshKey.decPublicKey || ''}>
|
||||
{props.selectedCipher.sshKey.decPublicKey || ''}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="kv-actions" />
|
||||
</div>
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_fingerprint')}</span>
|
||||
<div className="kv-main">
|
||||
<strong className="value-ellipsis" title={props.selectedCipher.sshKey.decFingerprint || ''}>
|
||||
{props.selectedCipher.sshKey.decFingerprint || ''}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="kv-actions" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!(props.selectedCipher.decNotes || '').trim() && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_notes')}</h4>
|
||||
<div className="notes">{props.selectedCipher.decNotes || ''}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(props.selectedCipher.fields || []).some((x) => parseFieldType(x.type) !== 3) && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_custom_fields')}</h4>
|
||||
{(props.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 = !!props.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={() => props.onToggleHiddenField(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={() => props.onDownloadAttachment(props.selectedCipher, attachmentId)}>
|
||||
<Download size={14} className="btn-icon" /> {t('txt_download')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(props.selectedCipher.creationDate || props.selectedCipher.revisionDate) && (
|
||||
<div className="card">
|
||||
<h4>{t('txt_item_history')}</h4>
|
||||
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
|
||||
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-actions">
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
|
||||
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}>
|
||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import type { CustomFieldType, Folder } from '@/lib/types';
|
||||
import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface VaultDialogsProps {
|
||||
fieldModalOpen: boolean;
|
||||
fieldType: CustomFieldType;
|
||||
fieldLabel: string;
|
||||
fieldValue: string;
|
||||
pendingDeleteOpen: boolean;
|
||||
bulkDeleteOpen: boolean;
|
||||
sidebarTrashMode: boolean;
|
||||
selectedCount: number;
|
||||
moveOpen: boolean;
|
||||
moveFolderId: string;
|
||||
folders: Folder[];
|
||||
createFolderOpen: boolean;
|
||||
newFolderName: string;
|
||||
pendingDeleteFolder: Folder | null;
|
||||
deleteAllFoldersOpen: boolean;
|
||||
repromptOpen: boolean;
|
||||
repromptPassword: string;
|
||||
onConfirmAddField: () => void;
|
||||
onCancelFieldModal: () => void;
|
||||
onFieldTypeChange: (value: CustomFieldType) => void;
|
||||
onFieldLabelChange: (value: string) => void;
|
||||
onFieldValueChange: (value: string) => void;
|
||||
onConfirmDelete: () => void;
|
||||
onCancelDelete: () => void;
|
||||
onConfirmBulkDelete: () => void;
|
||||
onCancelBulkDelete: () => void;
|
||||
onConfirmMove: () => void;
|
||||
onCancelMove: () => void;
|
||||
onMoveFolderIdChange: (value: string) => void;
|
||||
onConfirmCreateFolder: () => void;
|
||||
onCancelCreateFolder: () => void;
|
||||
onNewFolderNameChange: (value: string) => void;
|
||||
onConfirmDeleteFolder: () => void;
|
||||
onCancelDeleteFolder: () => void;
|
||||
onConfirmDeleteAllFolders: () => void;
|
||||
onCancelDeleteAllFolders: () => void;
|
||||
onConfirmReprompt: () => void;
|
||||
onCancelReprompt: () => void;
|
||||
onRepromptPasswordChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function VaultDialogs(props: VaultDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
open={props.fieldModalOpen}
|
||||
title={t('txt_add_field')}
|
||||
message={t('txt_configure_custom_field_values')}
|
||||
confirmText={t('txt_add')}
|
||||
cancelText={t('txt_cancel')}
|
||||
onConfirm={props.onConfirmAddField}
|
||||
onCancel={props.onCancelFieldModal}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_field_type')}</span>
|
||||
<select className="input" value={props.fieldType} onInput={(e) => props.onFieldTypeChange(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={props.fieldLabel} onInput={(e) => props.onFieldLabelChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
{props.fieldType === 2 ? (
|
||||
<label className="check-line">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toBooleanFieldValue(props.fieldValue)}
|
||||
onInput={(e) => props.onFieldValueChange((e.currentTarget as HTMLInputElement).checked ? 'true' : 'false')}
|
||||
/>
|
||||
{t('txt_enabled')}
|
||||
</label>
|
||||
) : (
|
||||
<label className="field">
|
||||
<span>{t('txt_field_value')}</span>
|
||||
<input className="input" value={props.fieldValue} onInput={(e) => props.onFieldValueChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog open={props.pendingDeleteOpen} title={t('txt_delete_item')} message={t('txt_are_you_sure_you_want_to_delete_this_item')} danger onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.bulkDeleteOpen}
|
||||
title={props.sidebarTrashMode ? t('txt_delete_selected_items_permanently') : t('txt_delete_selected_items')}
|
||||
message={
|
||||
props.sidebarTrashMode
|
||||
? t('txt_are_you_sure_you_want_to_delete_count_selected_items_permanently', { count: props.selectedCount })
|
||||
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount })
|
||||
}
|
||||
danger
|
||||
onConfirm={props.onConfirmBulkDelete}
|
||||
onCancel={props.onCancelBulkDelete}
|
||||
/>
|
||||
|
||||
<ConfirmDialog open={props.moveOpen} title={t('txt_move_selected_items')} message={t('txt_choose_destination_folder')} confirmText={t('txt_move')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmMove} onCancel={props.onCancelMove}>
|
||||
<label className="field">
|
||||
<span>{t('txt_folder')}</span>
|
||||
<select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((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={props.createFolderOpen} title={t('txt_create_folder')} message={t('txt_enter_a_folder_name')} confirmText={t('txt_create')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmCreateFolder} onCancel={props.onCancelCreateFolder}>
|
||||
<label className="field">
|
||||
<span>{t('txt_folder_name')}</span>
|
||||
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!props.pendingDeleteFolder}
|
||||
title={t('txt_delete_folder')}
|
||||
message={t('txt_delete_folder_message', { name: props.pendingDeleteFolder?.decName || props.pendingDeleteFolder?.name || props.pendingDeleteFolder?.id || '' })}
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
onConfirm={props.onConfirmDeleteFolder}
|
||||
onCancel={props.onCancelDeleteFolder}
|
||||
/>
|
||||
|
||||
<ConfirmDialog open={props.deleteAllFoldersOpen} title={t('txt_delete_all_folders')} message={t('txt_delete_all_folders_message')} confirmText={t('txt_delete')} cancelText={t('txt_cancel')} danger onConfirm={props.onConfirmDeleteAllFolders} onCancel={props.onCancelDeleteAllFolders} />
|
||||
|
||||
<ConfirmDialog open={props.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={props.onConfirmReprompt} onCancel={props.onCancelReprompt}>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import type { RefObject } from 'preact';
|
||||
import { CheckCheck, Download, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, formatAttachmentSize, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface VaultEditorProps {
|
||||
draft: VaultDraft;
|
||||
isCreating: boolean;
|
||||
busy: boolean;
|
||||
folders: Folder[];
|
||||
selectedCipher: Cipher | null;
|
||||
editExistingAttachments: Array<any>;
|
||||
removedAttachmentIds: Record<string, boolean>;
|
||||
removedAttachmentCount: number;
|
||||
attachmentQueue: File[];
|
||||
attachmentInputRef: RefObject<HTMLInputElement>;
|
||||
localError: string;
|
||||
onUpdateDraft: (patch: Partial<VaultDraft>) => void;
|
||||
onSeedSshDefaults: (force?: boolean) => void;
|
||||
onUpdateSshPublicKey: (value: string) => void;
|
||||
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
||||
onQueueAttachmentFiles: (list: FileList | null) => void;
|
||||
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
||||
onRemoveQueuedAttachment: (index: number) => void;
|
||||
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
|
||||
onPatchDraftCustomField: (index: number, patch: Partial<VaultDraftField>) => void;
|
||||
onUpdateDraftCustomFields: (fields: VaultDraftField[]) => void;
|
||||
onOpenFieldModal: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
}
|
||||
|
||||
export default function VaultEditor(props: VaultEditorProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<div className="section-head">
|
||||
<h3 className="detail-title">{props.isCreating ? t('txt_new_type_header', { type: cipherTypeLabel(props.draft.type) }) : t('txt_edit_type_header', { type: cipherTypeLabel(props.draft.type) })}</h3>
|
||||
<button type="button" className={`btn btn-secondary small ${props.draft.favorite ? 'star-on' : ''}`} onClick={() => props.onUpdateDraft({ favorite: !props.draft.favorite })}>
|
||||
{props.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={props.draft.type}
|
||||
disabled={!props.isCreating}
|
||||
onInput={(e) => {
|
||||
const nextType = Number((e.currentTarget as HTMLSelectElement).value);
|
||||
props.onUpdateDraft({ type: nextType });
|
||||
if (nextType === 5) props.onSeedSshDefaults();
|
||||
}}
|
||||
>
|
||||
{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={props.draft.folderId} onInput={(e) => props.onUpdateDraft({ 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={props.draft.name} onInput={(e) => props.onUpdateDraft({ name: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{props.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={props.draft.loginUsername} onInput={(e) => props.onUpdateDraft({ loginUsername: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_password')}</span>
|
||||
<input className="input" value={props.draft.loginPassword} onInput={(e) => props.onUpdateDraft({ loginPassword: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>{t('txt_totp_secret')}</span>
|
||||
<input className="input" value={props.draft.loginTotp} onInput={(e) => props.onUpdateDraft({ 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={() => props.onUpdateDraft({ loginUris: [...props.draft.loginUris, ''] })}>
|
||||
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
|
||||
</button>
|
||||
</div>
|
||||
{props.draft.loginUris.map((uri, index) => (
|
||||
<div key={`uri-${index}`} className="website-row">
|
||||
<input className="input" value={uri} onInput={(e) => props.onUpdateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} />
|
||||
{props.draft.loginUris.length > 1 && (
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, i) => i !== index) })}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.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={props.draft.cardholderName} onInput={(e) => props.onUpdateDraft({ cardholderName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_number')}</span><input className="input" value={props.draft.cardNumber} onInput={(e) => props.onUpdateDraft({ cardNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_brand')}</span><input className="input" value={props.draft.cardBrand} onInput={(e) => props.onUpdateDraft({ cardBrand: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_security_code_cvv')}</span><input className="input" value={props.draft.cardCode} onInput={(e) => props.onUpdateDraft({ cardCode: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_expiry_month')}</span><input className="input" value={props.draft.cardExpMonth} onInput={(e) => props.onUpdateDraft({ cardExpMonth: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_expiry_year')}</span><input className="input" value={props.draft.cardExpYear} onInput={(e) => props.onUpdateDraft({ cardExpYear: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.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={props.draft.identTitle} onInput={(e) => props.onUpdateDraft({ identTitle: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_first_name')}</span><input className="input" value={props.draft.identFirstName} onInput={(e) => props.onUpdateDraft({ identFirstName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_middle_name')}</span><input className="input" value={props.draft.identMiddleName} onInput={(e) => props.onUpdateDraft({ identMiddleName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_last_name')}</span><input className="input" value={props.draft.identLastName} onInput={(e) => props.onUpdateDraft({ identLastName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_username')}</span><input className="input" value={props.draft.identUsername} onInput={(e) => props.onUpdateDraft({ identUsername: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_company')}</span><input className="input" value={props.draft.identCompany} onInput={(e) => props.onUpdateDraft({ identCompany: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_ssn')}</span><input className="input" value={props.draft.identSsn} onInput={(e) => props.onUpdateDraft({ identSsn: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_passport_number')}</span><input className="input" value={props.draft.identPassportNumber} onInput={(e) => props.onUpdateDraft({ identPassportNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_license_number')}</span><input className="input" value={props.draft.identLicenseNumber} onInput={(e) => props.onUpdateDraft({ identLicenseNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_email')}</span><input className="input" value={props.draft.identEmail} onInput={(e) => props.onUpdateDraft({ identEmail: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_phone')}</span><input className="input" value={props.draft.identPhone} onInput={(e) => props.onUpdateDraft({ identPhone: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_address_1')}</span><input className="input" value={props.draft.identAddress1} onInput={(e) => props.onUpdateDraft({ identAddress1: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_address_2')}</span><input className="input" value={props.draft.identAddress2} onInput={(e) => props.onUpdateDraft({ identAddress2: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_address_3')}</span><input className="input" value={props.draft.identAddress3} onInput={(e) => props.onUpdateDraft({ identAddress3: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_city_town')}</span><input className="input" value={props.draft.identCity} onInput={(e) => props.onUpdateDraft({ identCity: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_state_province')}</span><input className="input" value={props.draft.identState} onInput={(e) => props.onUpdateDraft({ identState: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_postal_code')}</span><input className="input" value={props.draft.identPostalCode} onInput={(e) => props.onUpdateDraft({ identPostalCode: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
<label className="field"><span>{t('txt_country')}</span><input className="input" value={props.draft.identCountry} onInput={(e) => props.onUpdateDraft({ identCountry: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.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={() => props.onSeedSshDefaults(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={props.draft.sshPrivateKey} onInput={(e) => props.onUpdateDraft({ sshPrivateKey: (e.currentTarget as HTMLTextAreaElement).value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_public_key')}</span>
|
||||
<textarea className="input textarea" value={props.draft.sshPublicKey} onInput={(e) => props.onUpdateSshPublicKey((e.currentTarget as HTMLTextAreaElement).value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_fingerprint')}</span>
|
||||
<input className="input input-readonly" value={props.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={props.busy}
|
||||
onClick={() => props.attachmentInputRef.current?.click()}
|
||||
title={t('txt_upload_attachments')}
|
||||
aria-label={t('txt_upload_attachments')}
|
||||
>
|
||||
<Plus size={14} className="btn-icon" />
|
||||
</button>
|
||||
</div>
|
||||
{!props.isCreating && props.selectedCipher && props.editExistingAttachments.length > 0 && (
|
||||
<div className="attachment-list">
|
||||
{props.editExistingAttachments.map((attachment) => {
|
||||
const attachmentId = String(attachment?.id || '').trim();
|
||||
if (!attachmentId) return null;
|
||||
const removed = !!props.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={props.busy || removed} onClick={() => props.onDownloadAttachment(props.selectedCipher as Cipher, attachmentId)}>
|
||||
<Download size={14} className="btn-icon" /> {t('txt_download')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={() => props.onToggleExistingAttachmentRemoval(attachmentId)}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{removed ? t('txt_cancel') : t('txt_remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!!props.removedAttachmentCount && <div className="detail-sub">{t('txt_marked_for_removal_count', { count: props.removedAttachmentCount })}</div>}
|
||||
<input
|
||||
ref={props.attachmentInputRef}
|
||||
type="file"
|
||||
className="attachment-file-input"
|
||||
multiple
|
||||
disabled={props.busy}
|
||||
onChange={(e) => {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
props.onQueueAttachmentFiles(input.files);
|
||||
input.value = '';
|
||||
}}
|
||||
/>
|
||||
{!!props.attachmentQueue.length && (
|
||||
<div className="attachment-list">
|
||||
<div className="attachment-queue-title">{t('txt_new_attachments')}</div>
|
||||
{props.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={props.busy} onClick={() => props.onRemoveQueuedAttachment(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={props.draft.notes} onInput={(e) => props.onUpdateDraft({ notes: (e.currentTarget as HTMLTextAreaElement).value })} />
|
||||
</label>
|
||||
<label className="check-line">
|
||||
<input type="checkbox" checked={props.draft.reprompt} onInput={(e) => props.onUpdateDraft({ 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={props.onOpenFieldModal}>
|
||||
<Plus size={14} className="btn-icon" /> {t('txt_add_field')}
|
||||
</button>
|
||||
</div>
|
||||
{props.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) => props.onPatchDraftCustomField(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) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onUpdateDraftCustomFields(props.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={props.busy} onClick={props.onSave}>
|
||||
<CheckCheck size={14} className="btn-icon" />
|
||||
{t('txt_confirm')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={props.busy} onClick={props.onCancel}>
|
||||
<X size={14} className="btn-icon" />
|
||||
{t('txt_cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{!props.isCreating && props.selectedCipher && (
|
||||
<button type="button" className="btn btn-danger" disabled={props.busy} onClick={props.onDeleteSelected}>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{t('txt_delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{props.localError && <div className="local-error">{props.localError}</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { RefObject } from 'preact';
|
||||
import { ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, Trash2, X } from 'lucide-preact';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
CREATE_TYPE_OPTIONS,
|
||||
CreateTypeIcon,
|
||||
VAULT_SORT_OPTIONS,
|
||||
VaultListIcon,
|
||||
type SidebarFilter,
|
||||
type VaultSortMode,
|
||||
} from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface VirtualRange {
|
||||
start: number;
|
||||
end: number;
|
||||
padTop: number;
|
||||
padBottom: number;
|
||||
}
|
||||
|
||||
interface VaultListPanelProps {
|
||||
busy: boolean;
|
||||
loading: boolean;
|
||||
searchInput: string;
|
||||
sortMode: VaultSortMode;
|
||||
sortMenuOpen: boolean;
|
||||
selectedCount: number;
|
||||
totalCipherCount: number;
|
||||
filteredCiphers: Cipher[];
|
||||
visibleCiphers: Cipher[];
|
||||
virtualRange: VirtualRange;
|
||||
selectedCipherId: string;
|
||||
selectedMap: Record<string, boolean>;
|
||||
sidebarFilter: SidebarFilter;
|
||||
createMenuOpen: boolean;
|
||||
createMenuRef: RefObject<HTMLDivElement>;
|
||||
sortMenuRef: RefObject<HTMLDivElement>;
|
||||
listPanelRef: RefObject<HTMLDivElement>;
|
||||
onSearchInput: (value: string) => void;
|
||||
onSearchCompositionStart: () => void;
|
||||
onSearchCompositionEnd: (value: string) => void;
|
||||
onToggleSortMenu: () => void;
|
||||
onSelectSortMode: (value: VaultSortMode) => void;
|
||||
onSyncVault: () => void;
|
||||
onOpenBulkDelete: () => void;
|
||||
onSelectAll: () => void;
|
||||
onToggleCreateMenu: () => void;
|
||||
onStartCreate: (type: number) => void;
|
||||
onBulkRestore: () => void;
|
||||
onOpenMove: () => void;
|
||||
onClearSelection: () => void;
|
||||
onScroll: (top: number) => void;
|
||||
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||
onSelectCipher: (cipherId: string) => void;
|
||||
listSubtitle: (cipher: Cipher) => string;
|
||||
}
|
||||
|
||||
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||
return (
|
||||
<section className="list-col">
|
||||
<div className="list-head">
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder={t('txt_search_your_secure_vault')}
|
||||
value={props.searchInput}
|
||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||
onCompositionStart={props.onSearchCompositionStart}
|
||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-secondary small sort-trigger ${props.sortMenuOpen ? 'active' : ''}`}
|
||||
aria-label={t('txt_sort')}
|
||||
title={t('txt_sort')}
|
||||
onClick={props.onToggleSortMenu}
|
||||
>
|
||||
<ArrowUpDown size={14} className="btn-icon" />
|
||||
</button>
|
||||
{props.sortMenuOpen && (
|
||||
<div className="sort-menu">
|
||||
{VAULT_SORT_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
|
||||
onClick={() => props.onSelectSortMode(option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-count" title={t('txt_total_items_count', { count: props.totalCipherCount })}>
|
||||
{t('txt_total_items_count', { count: props.totalCipherCount })}
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
||||
<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={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
||||
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||
</button>
|
||||
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary small mobile-fab-trigger"
|
||||
aria-label={t('txt_add')}
|
||||
title={t('txt_add')}
|
||||
onClick={props.onToggleCreateMenu}
|
||||
>
|
||||
<Plus size={14} className="btn-icon" />
|
||||
</button>
|
||||
{props.createMenuOpen && (
|
||||
<div className="create-menu">
|
||||
{CREATE_TYPE_OPTIONS.map((option) => (
|
||||
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
|
||||
<CreateTypeIcon type={option.type} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && (
|
||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
||||
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
||||
</button>
|
||||
)}
|
||||
{props.selectedCount > 0 && (
|
||||
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
|
||||
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||
{!!props.filteredCiphers.length && (
|
||||
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||
{props.visibleCiphers.map((cipher) => (
|
||||
<div key={cipher.id} className={`list-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="row-check"
|
||||
checked={!!props.selectedMap[cipher.id]}
|
||||
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
|
||||
<div className="list-icon-wrap">
|
||||
<VaultListIcon cipher={cipher} />
|
||||
</div>
|
||||
<div className="list-text">
|
||||
<span className="list-title" title={cipher.decName || t('txt_no_name')}>
|
||||
<span className="list-title-text">{cipher.decName || t('txt_no_name')}</span>
|
||||
</span>
|
||||
<span className="list-sub" title={props.listSubtitle(cipher)}>{props.listSubtitle(cipher)}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
CreditCard,
|
||||
Folder as FolderIcon,
|
||||
FolderPlus,
|
||||
FolderX,
|
||||
Globe,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
ShieldUser,
|
||||
Star,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-preact';
|
||||
import type { Folder } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { SidebarFilter } from '@/components/vault/vault-page-helpers';
|
||||
|
||||
interface VaultSidebarProps {
|
||||
folders: Folder[];
|
||||
sidebarFilter: SidebarFilter;
|
||||
busy: boolean;
|
||||
isMobileLayout: boolean;
|
||||
mobileSidebarOpen: boolean;
|
||||
onCloseMobileSidebar: () => void;
|
||||
onChangeFilter: (filter: SidebarFilter) => void;
|
||||
onOpenDeleteAllFolders: () => void;
|
||||
onOpenCreateFolder: () => void;
|
||||
onOpenDeleteFolder: (folder: Folder) => void;
|
||||
}
|
||||
|
||||
export default function VaultSidebar(props: VaultSidebarProps) {
|
||||
return (
|
||||
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
|
||||
{props.isMobileLayout && (
|
||||
<div className="mobile-sidebar-head">
|
||||
<div className="mobile-sidebar-title">{t('txt_folders')}</div>
|
||||
<button type="button" className="mobile-sidebar-close" onClick={props.onCloseMobileSidebar} aria-label={t('txt_close')}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-block">
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}>
|
||||
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'favorite' })}>
|
||||
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ 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 ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'login' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ 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 ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ 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 ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ 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 ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ 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 ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'ssh' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ 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>
|
||||
<div className="folder-title-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="folder-delete-btn"
|
||||
title={t('txt_delete_all_folders')}
|
||||
aria-label={t('txt_delete_all_folders')}
|
||||
disabled={props.busy || props.folders.length === 0}
|
||||
onClick={props.onOpenDeleteAllFolders}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<button type="button" className="folder-add-btn" onClick={props.onOpenCreateFolder}>
|
||||
<FolderPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => props.onChangeFilter({ 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 ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === folder.id ? 'active' : ''}`}
|
||||
onClick={() => props.onChangeFilter({ 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={props.busy}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
props.onOpenDeleteFolder(folder);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import {
|
||||
CreditCard,
|
||||
FileKey2,
|
||||
Globe,
|
||||
KeyRound,
|
||||
ShieldUser,
|
||||
StickyNote,
|
||||
} from 'lucide-preact';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||
|
||||
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||
export type SidebarFilter =
|
||||
| { kind: 'all' }
|
||||
| { kind: 'favorite' }
|
||||
| { kind: 'trash' }
|
||||
| { kind: 'type'; value: TypeFilter }
|
||||
| { kind: 'folder'; folderId: string | null };
|
||||
|
||||
interface TypeOption {
|
||||
type: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export 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') },
|
||||
];
|
||||
|
||||
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
||||
export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||
export const VAULT_LIST_ROW_HEIGHT = 66;
|
||||
export const VAULT_LIST_OVERSCAN = 10;
|
||||
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||
{ value: 'created', label: t('txt_sort_created') },
|
||||
{ value: 'name', label: t('txt_sort_name') },
|
||||
];
|
||||
|
||||
export 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') },
|
||||
];
|
||||
|
||||
export const TOTP_PERIOD_SECONDS = 30;
|
||||
export const TOTP_RING_RADIUS = 14;
|
||||
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||
|
||||
export 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} />;
|
||||
}
|
||||
|
||||
export 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';
|
||||
}
|
||||
|
||||
export 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');
|
||||
}
|
||||
|
||||
export 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} />;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function toBooleanFieldValue(raw: string): boolean {
|
||||
const v = String(raw || '').trim().toLowerCase();
|
||||
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
||||
}
|
||||
|
||||
export 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 '';
|
||||
}
|
||||
|
||||
export 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 '';
|
||||
}
|
||||
}
|
||||
|
||||
export 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: [],
|
||||
};
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function maskSecret(value: string): string {
|
||||
if (!value) return '';
|
||||
return '*'.repeat(Math.max(8, Math.min(24, value.length)));
|
||||
}
|
||||
|
||||
export function formatTotp(code: string): string {
|
||||
if (!code || code.length < 6) return code;
|
||||
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
||||
}
|
||||
|
||||
export 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();
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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`;
|
||||
}
|
||||
|
||||
export function sortTimeValue(cipher: Cipher): number {
|
||||
const candidates = [cipher.revisionDate, cipher.creationDate];
|
||||
for (const value of candidates) {
|
||||
const time = new Date(String(value || '')).getTime();
|
||||
if (Number.isFinite(time)) return time;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function creationTimeValue(cipher: Cipher): number {
|
||||
const time = new Date(String(cipher.creationDate || '')).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
export 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 failedIconHosts = new Set<string>();
|
||||
|
||||
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||
const uri = firstCipherUri(cipher);
|
||||
const host = hostFromUri(uri);
|
||||
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||
if (host && !errored) {
|
||||
return (
|
||||
<img
|
||||
className="list-icon"
|
||||
src={`/icons/${host}/icon.png?v=2`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => {
|
||||
failedIconHosts.add(host);
|
||||
setErrored(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="list-icon-fallback">
|
||||
<TypeIcon type={Number(cipher.type || 1)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function copyToClipboard(value: string): void {
|
||||
if (!value.trim()) return;
|
||||
void navigator.clipboard.writeText(value);
|
||||
}
|
||||
|
||||
export 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');
|
||||
}
|
||||
Reference in New Issue
Block a user