feat: enhance backup and download functionalities

- Updated `BackupCenterPage` to support download progress tracking during remote backup downloads.
- Modified `ImportPage` to simplify export functionality by removing unnecessary payload handling.
- Improved `JwtWarningPage` to utilize a new clipboard utility for copying text with feedback.
- Enhanced `PublicSendPage` to show download progress for files being downloaded.
- Updated `RecoverTwoFactorPage` to include autocomplete attributes for better user experience.
- Refactored `SendsPage` to use the new clipboard utility for copying access URLs.
- Enhanced `SettingsPage` to utilize the clipboard utility for copying sensitive information.
- Improved `TotpCodesPage` to use the clipboard utility for copying TOTP codes.
- Updated `VaultPage` and related components to support download progress for attachments.
- Introduced a new `app-notify` module for consistent notification handling across the application.
- Created a `clipboard` utility for improved clipboard interactions with user feedback.
- Added progress tracking for file downloads in the API layer, enhancing user experience during downloads.
This commit is contained in:
shuaiplus
2026-03-15 23:12:45 +08:00
parent 9820c2ed44
commit 4b8cad6d00
33 changed files with 387 additions and 121 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
import { useState } from 'preact/hooks';
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import type { AdminInvite, AdminUser } from '@/lib/types';
import { t } from '@/lib/i18n';
@@ -134,7 +135,7 @@ export default function AdminPage(props: AdminPageProps) {
<button
type="button"
className="btn btn-secondary"
onClick={() => navigator.clipboard.writeText(invite.inviteLink || '')}
onClick={() => void copyTextToClipboard(invite.inviteLink || '', { successMessage: t('txt_link_copied') })}
>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
</button>
+2 -2
View File
@@ -64,7 +64,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
>
<label className="field">
<span>{t('txt_totp_code')}</span>
<input className="input" value={props.totpCode} onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
<input className="input" value={props.totpCode} autoComplete="one-time-code" 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)} />
@@ -85,7 +85,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
>
<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)} />
<input className="input" type="password" autoComplete="current-password" value={props.disableTotpPassword} onInput={(e) => props.onDisableTotpPasswordChange((e.currentTarget as HTMLInputElement).value)} />
</label>
</ConfirmDialog>
+5 -1
View File
@@ -65,6 +65,8 @@ export interface AppMainRoutesProps {
onDeleteFolder: (folderId: string) => Promise<void>;
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
downloadingAttachmentKey: string;
attachmentDownloadPercent: number | null;
onRefreshVault: () => Promise<void>;
onCreateSend: (draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
onUpdateSend: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
@@ -91,7 +93,7 @@ export interface AppMainRoutesProps {
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<void>;
}
@@ -167,6 +169,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onDeleteFolder={props.onDeleteFolder}
onBulkDeleteFolders={props.onBulkDeleteFolders}
onDownloadAttachment={props.onDownloadVaultAttachment}
downloadingAttachmentKey={props.downloadingAttachmentKey}
attachmentDownloadPercent={props.attachmentDownloadPercent}
/>
</Suspense>
</Route>
+11
View File
@@ -39,6 +39,7 @@ function PasswordField(props: {
value: string;
onInput: (v: string) => void;
autoFocus?: boolean;
autoComplete?: string;
}) {
const [show, setShow] = useState(false);
return (
@@ -51,6 +52,7 @@ function PasswordField(props: {
value={props.value}
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
autoFocus={props.autoFocus}
autoComplete={props.autoComplete}
/>
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
{show ? <EyeOff size={16} /> : <Eye size={16} />}
@@ -76,10 +78,12 @@ export default function AuthViews(props: AuthViewsProps) {
}}
>
<p className="muted standalone-muted">{props.emailForLock}</p>
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
<PasswordField
label={t('txt_master_password')}
value={props.unlockPassword}
autoFocus
autoComplete="current-password"
onInput={props.onChangeUnlock}
/>
<button type="submit" className="btn btn-primary full" disabled={unlockBusy}>
@@ -112,6 +116,7 @@ export default function AuthViews(props: AuthViewsProps) {
<input
className="input"
value={props.registerValues.name}
autoComplete="name"
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, name: (e.currentTarget as HTMLInputElement).value })
}
@@ -123,6 +128,7 @@ export default function AuthViews(props: AuthViewsProps) {
className="input"
type="email"
value={props.registerValues.email}
autoComplete="email"
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, email: (e.currentTarget as HTMLInputElement).value })
}
@@ -131,11 +137,13 @@ export default function AuthViews(props: AuthViewsProps) {
<PasswordField
label={t('txt_master_password')}
value={props.registerValues.password}
autoComplete="new-password"
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password: v })}
/>
<PasswordField
label={t('txt_confirm_master_password')}
value={props.registerValues.password2}
autoComplete="new-password"
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
/>
<label className="field">
@@ -143,6 +151,7 @@ export default function AuthViews(props: AuthViewsProps) {
<input
className="input"
value={props.registerValues.inviteCode}
autoComplete="off"
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, inviteCode: (e.currentTarget as HTMLInputElement).value })
}
@@ -178,12 +187,14 @@ export default function AuthViews(props: AuthViewsProps) {
className="input"
type="email"
value={props.loginValues.email}
autoComplete="username"
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
/>
</label>
<PasswordField
label={t('txt_master_password')}
value={props.loginValues.password}
autoComplete="current-password"
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus
/>
+6 -2
View File
@@ -35,7 +35,7 @@ interface BackupCenterPageProps {
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<void>;
onNotify: (type: 'success' | 'error', text: string) => void;
@@ -54,6 +54,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [runningRemoteBackup, setRunningRemoteBackup] = useState(false);
const [loadingRemoteBrowser, setLoadingRemoteBrowser] = useState(false);
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
const [restoringRemotePath, setRestoringRemotePath] = useState('');
const [deletingRemotePath, setDeletingRemotePath] = useState('');
const [localError, setLocalError] = useState('');
@@ -367,15 +368,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleDownloadRemote(path: string) {
if (!savedSelectedDestination) return;
setDownloadingRemotePath(path);
setDownloadingRemotePercent(null);
setLocalError('');
try {
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path);
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
setLocalError(message);
props.onNotify('error', message);
} finally {
setDownloadingRemotePath('');
setDownloadingRemotePercent(null);
}
}
@@ -479,6 +482,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
remoteBrowserTotalPages={remoteBrowserTotalPages}
loadingRemoteBrowser={loadingRemoteBrowser}
downloadingRemotePath={downloadingRemotePath}
downloadingRemotePercent={downloadingRemotePercent}
restoringRemotePath={restoringRemotePath}
deletingRemotePath={deletingRemotePath}
onSaveSettings={() => void handleSaveSettings()}
+2 -13
View File
@@ -8,7 +8,6 @@ import type { CiphersImportPayload } from '@/lib/api/vault';
import {
type EncryptedJsonMode,
EXPORT_FORMATS,
type ExportDownloadPayload,
type ExportFormatId,
type ExportRequest,
} from '@/lib/export-formats';
@@ -48,7 +47,7 @@ interface ImportPageProps {
accountKeys?: { encB64: string; macB64: string } | null;
onNotify: (type: 'success' | 'error', text: string) => void;
folders: Folder[];
onExport: (request: ExportRequest) => Promise<ExportDownloadPayload>;
onExport: (request: ExportRequest) => Promise<void>;
}
export interface ImportResultSummary {
@@ -539,23 +538,13 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
setIsExporting(true);
try {
const payload = await onExport({
await onExport({
format: exportFormat,
encryptedJsonMode: exportNeedsMode ? encryptedJsonMode : undefined,
filePassword,
zipPassword: exportIsZip ? zipPass : '',
masterPassword,
});
const blobBytes = Uint8Array.from(payload.bytes);
const blob = new Blob([blobBytes], { type: payload.mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = payload.fileName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
onNotify('success', t('txt_export_completed'));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_export_failed');
+5 -2
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'preact/hooks';
import { AlertTriangle, Copy, RefreshCw } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
@@ -55,8 +56,10 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
type="button"
className="btn btn-secondary"
onClick={async () => {
await navigator.clipboard.writeText(generatedSecret);
setCopyHint(t('txt_copied'));
await copyTextToClipboard(generatedSecret, {
onSuccess: () => setCopyHint(t('txt_copied')),
onError: () => setCopyHint(t('txt_copy_failed')),
});
window.setTimeout(() => setCopyHint(''), 1500);
}}
>
+12 -10
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'preact/hooks';
import { Download, Eye, Lock } from 'lucide-preact';
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
@@ -16,6 +17,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
const [error, setError] = useState('');
const [sendData, setSendData] = useState<any>(null);
const [busy, setBusy] = useState(false);
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
async function loadSend(pass?: string): Promise<void> {
setBusy(true);
@@ -48,12 +50,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
async function downloadFile(): Promise<void> {
if (!sendData?.id || !sendData?.file?.id) return;
setBusy(true);
setDownloadPercent(null);
setError('');
try {
const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
const resp = await fetch(url);
if (!resp.ok) throw new Error(t('txt_download_failed'));
const encryptedBytes = await resp.arrayBuffer();
const encryptedBytes = await readResponseBytesWithProgress(resp, (progress) => setDownloadPercent(progress.percent));
let blob: Blob;
if (props.keyPart) {
try {
@@ -66,19 +69,17 @@ export default function PublicSendPage(props: PublicSendPageProps) {
} else {
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
}
const obj = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = obj;
a.download = sendData.decFileName || sendData.file?.fileName || t('txt_send_file');
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(obj);
downloadBytesAsFile(
new Uint8Array(await blob.arrayBuffer()),
sendData.decFileName || sendData.file?.fileName || t('txt_send_file'),
'application/octet-stream'
);
} catch (e) {
const err = e as Error;
setError(err.message || t('txt_download_failed'));
} finally {
setBusy(false);
setDownloadPercent(null);
}
}
@@ -105,6 +106,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
className="input"
type="password"
value={password}
autoComplete="current-password"
onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}
/>
</div>
@@ -129,7 +131,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
</div>
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void downloadFile()}>
<Download size={14} className="btn-icon" /> {t('txt_download')}
<Download size={14} className="btn-icon" /> {downloadPercent == null ? (busy ? t('txt_downloading') : t('txt_download')) : t('txt_downloading_percent', { percent: downloadPercent })}
</button>
</div>
)}
@@ -30,6 +30,7 @@ export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
className="input"
type="email"
value={props.values.email}
autoComplete="username"
onInput={(e) => props.onChange({ ...props.values, email: (e.currentTarget as HTMLInputElement).value })}
/>
</label>
@@ -41,6 +42,7 @@ export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
className="input"
type={showPassword ? 'text' : 'password'}
value={props.values.password}
autoComplete="current-password"
onInput={(e) => props.onChange({ ...props.values, password: (e.currentTarget as HTMLInputElement).value })}
/>
<button type="button" className="eye-btn" onClick={() => setShowPassword((v) => !v)}>
@@ -54,6 +56,7 @@ export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
<input
className="input"
value={props.values.recoveryCode}
autoComplete="one-time-code"
onInput={(e) => props.onChange({ ...props.values, recoveryCode: (e.currentTarget as HTMLInputElement).value.toUpperCase() })}
/>
</label>
+2 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import type { Send, SendDraft } from '@/lib/types';
import { t } from '@/lib/i18n';
@@ -211,8 +212,7 @@ export default function SendsPage(props: SendsPageProps) {
function copyAccessUrl(send: Send): void {
const url = send.shareUrl || `${window.location.origin}/#/send/${send.accessId}`;
void navigator.clipboard.writeText(url);
props.onNotify('success', t('txt_link_copied'));
void copyTextToClipboard(url, { successMessage: t('txt_link_copied') });
}
return (
+3 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator';
import type { Profile } from '@/lib/types';
import { t } from '@/lib/i18n';
@@ -144,8 +145,7 @@ export default function SettingsPage(props: SettingsPageProps) {
className="btn btn-secondary"
disabled={totpLocked}
onClick={() => {
void navigator.clipboard.writeText(secret);
props.onNotify?.('success', t('txt_secret_copied'));
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
}}
>
<Clipboard size={14} className="btn-icon" />
@@ -185,8 +185,7 @@ export default function SettingsPage(props: SettingsPageProps) {
className="btn btn-secondary"
disabled={!recoveryCode}
onClick={() => {
void navigator.clipboard.writeText(recoveryCode);
props.onNotify?.('success', t('txt_recovery_code_copied'));
void copyTextToClipboard(recoveryCode, { successMessage: t('txt_recovery_code_copied') });
}}
>
<Clipboard size={14} className="btn-icon" />
+11 -8
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Clipboard, Globe } from 'lucide-preact';
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
import { calcTotpNow } from '@/lib/crypto';
import { t } from '@/lib/i18n';
import type { Cipher } from '@/lib/types';
import { websiteIconUrl } from '@/components/vault/vault-page-helpers';
interface TotpCodesPageProps {
ciphers: Cipher[];
@@ -13,6 +15,7 @@ interface TotpCodesPageProps {
const TOTP_PERIOD_SECONDS = 30;
const TOTP_RING_RADIUS = 14;
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
const failedIconHosts = new Set<string>();
function formatTotp(code: string): string {
if (!code || code.length < 6) return code;
@@ -41,20 +44,22 @@ function hostFromUri(uri: string): string {
function TotpListIcon({ cipher }: { cipher: Cipher }) {
const uri = firstCipherUri(cipher);
const host = hostFromUri(uri);
const [errored, setErrored] = useState(false);
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
if (host && !errored) {
return (
<img
className="list-icon"
src={`/icons/${host}/icon.png?v=2`}
src={websiteIconUrl(host)}
alt=""
loading="lazy"
onError={() => setErrored(true)}
referrerPolicy="no-referrer"
onError={() => {
failedIconHosts.add(host);
setErrored(true);
}}
/>
);
}
return (
<span className="list-icon-fallback">
<Globe size={18} />
@@ -68,9 +73,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
const listRef = useRef<HTMLDivElement | null>(null);
async function copyToClipboard(value: string): Promise<void> {
if (!value.trim()) return;
await navigator.clipboard.writeText(value);
props.onNotify('success', t('txt_code_copied'));
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
}
const totpItems = useMemo(
+6
View File
@@ -55,6 +55,8 @@ interface VaultPageProps {
onDeleteFolder: (folderId: string) => Promise<void>;
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
downloadingAttachmentKey: string;
attachmentDownloadPercent: number | null;
}
@@ -793,6 +795,8 @@ function folderName(id: string | null | undefined): string {
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
onRemoveQueuedAttachment={removeQueuedAttachment}
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
downloadingAttachmentKey={props.downloadingAttachmentKey}
attachmentDownloadPercent={props.attachmentDownloadPercent}
onPatchDraftCustomField={patchDraftCustomField}
onUpdateDraftCustomFields={updateDraftCustomFields}
onOpenFieldModal={() => setFieldModalOpen(true)}
@@ -815,6 +819,8 @@ function folderName(id: string | null | undefined): string {
onToggleShowPassword={() => setShowPassword((value) => !value)}
onToggleHiddenField={(index) => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
downloadingAttachmentKey={props.downloadingAttachmentKey}
attachmentDownloadPercent={props.attachmentDownloadPercent}
onStartEdit={startEdit}
onDelete={setPendingDelete}
/>
@@ -27,6 +27,7 @@ interface BackupDestinationDetailProps {
remoteBrowserTotalPages: number;
loadingRemoteBrowser: boolean;
downloadingRemotePath: string;
downloadingRemotePercent: number | null;
restoringRemotePath: string;
deletingRemotePath: string;
onSaveSettings: () => void;
@@ -511,6 +512,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
currentPage={props.remoteBrowserCurrentPage}
totalPages={props.remoteBrowserTotalPages}
downloadingRemotePath={props.downloadingRemotePath}
downloadingRemotePercent={props.downloadingRemotePercent}
restoringRemotePath={props.restoringRemotePath}
deletingRemotePath={props.deletingRemotePath}
onRefresh={props.onRefreshRemoteBrowser}
@@ -13,6 +13,7 @@ interface RemoteBackupBrowserProps {
currentPage: number;
totalPages: number;
downloadingRemotePath: string;
downloadingRemotePercent: number | null;
restoringRemotePath: string;
deletingRemotePath: string;
onRefresh: () => void;
@@ -24,6 +25,13 @@ interface RemoteBackupBrowserProps {
}
export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
const getDownloadLabel = (path: string) => {
if (props.downloadingRemotePath !== path) return t('txt_backup_remote_download');
return props.downloadingRemotePercent == null
? t('txt_downloading')
: t('txt_downloading_percent', { percent: props.downloadingRemotePercent });
};
return (
<>
<div className="backup-divider" />
@@ -98,7 +106,7 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
<>
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || props.downloadingRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onDownload(item.path)}>
<Download size={14} className="btn-icon" />
{props.downloadingRemotePath === item.path ? t('txt_backup_remote_downloading') : t('txt_backup_remote_download')}
{getDownloadLabel(item.path)}
</button>
<button type="button" className="btn btn-primary small" disabled={props.disableWhileBusy || props.restoringRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onRestore(item.path)}>
<RotateCcw size={14} className="btn-icon" />
@@ -1,3 +1,4 @@
import { useState } from 'preact/hooks';
import { Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, Trash2 } from 'lucide-preact';
import type { Cipher } from '@/lib/types';
import { t } from '@/lib/i18n';
@@ -22,6 +23,8 @@ interface VaultDetailViewProps {
passkeyCreatedAt: string | null;
hiddenFieldVisibleMap: Record<number, boolean>;
folderName: (id: string | null | undefined) => string;
downloadingAttachmentKey: string;
attachmentDownloadPercent: number | null;
onOpenReprompt: () => void;
onToggleShowPassword: () => void;
onToggleHiddenField: (index: number) => void;
@@ -32,6 +35,14 @@ interface VaultDetailViewProps {
export default function VaultDetailView(props: VaultDetailViewProps) {
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
return props.attachmentDownloadPercent == null
? t('txt_downloading')
: t('txt_downloading_percent', { percent: props.attachmentDownloadPercent });
};
return (
<>
@@ -188,11 +199,22 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
<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
className="value-ellipsis"
title={showSshPrivateKey ? props.selectedCipher.sshKey.decPrivateKey || '' : maskSecret(props.selectedCipher.sshKey.decPrivateKey || '')}
>
{showSshPrivateKey ? props.selectedCipher.sshKey.decPrivateKey || '' : maskSecret(props.selectedCipher.sshKey.decPrivateKey || '')}
</strong>
</div>
<div className="kv-actions" />
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => setShowSshPrivateKey((value) => !value)}>
{showSshPrivateKey ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
{showSshPrivateKey ? t('txt_hide') : t('txt_reveal')}
</button>
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.sshKey?.decPrivateKey || '')}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
<div className="kv-row">
<span className="kv-label">{t('txt_public_key')}</span>
@@ -201,7 +223,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
{props.selectedCipher.sshKey.decPublicKey || ''}
</strong>
</div>
<div className="kv-actions" />
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.sshKey?.decPublicKey || '')}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
<div className="kv-row">
<span className="kv-label">{t('txt_fingerprint')}</span>
@@ -210,7 +236,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
{props.selectedCipher.sshKey.decFingerprint || ''}
</strong>
</div>
<div className="kv-actions" />
<div className="kv-actions">
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.sshKey?.decFingerprint || '')}>
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
</button>
</div>
</div>
</div>
)}
@@ -292,8 +322,13 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
</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
type="button"
className="btn btn-secondary small"
disabled={props.downloadingAttachmentKey === `${props.selectedCipher.id}:${attachmentId}`}
onClick={() => props.onDownloadAttachment(props.selectedCipher, attachmentId)}
>
<Download size={14} className="btn-icon" /> {formatDownloadLabel(attachmentId)}
</button>
</div>
</div>
+35 -5
View File
@@ -16,6 +16,8 @@ interface VaultEditorProps {
attachmentQueue: File[];
attachmentInputRef: RefObject<HTMLInputElement>;
localError: string;
downloadingAttachmentKey: string;
attachmentDownloadPercent: number | null;
onUpdateDraft: (patch: Partial<VaultDraft>) => void;
onSeedSshDefaults: (force?: boolean) => void;
onUpdateSshPublicKey: (value: string) => void;
@@ -33,6 +35,14 @@ interface VaultEditorProps {
}
export default function VaultEditor(props: VaultEditorProps) {
const formatDownloadLabel = (attachmentId: string) => {
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
return props.attachmentDownloadPercent == null
? t('txt_downloading')
: t('txt_downloading_percent', { percent: props.attachmentDownloadPercent });
};
return (
<>
<div className="card">
@@ -162,17 +172,32 @@ export default function VaultEditor(props: VaultEditorProps) {
<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)}>
<button
type="button"
className="btn btn-secondary small"
disabled={!props.isCreating}
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 })} />
<textarea
className="input textarea"
value={props.draft.sshPrivateKey}
disabled={!props.isCreating}
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)} />
<textarea
className="input textarea"
value={props.draft.sshPublicKey}
disabled={!props.isCreating}
onInput={(e) => props.onUpdateSshPublicKey((e.currentTarget as HTMLTextAreaElement).value)}
/>
</label>
<label className="field">
<span>{t('txt_fingerprint')}</span>
@@ -212,8 +237,13 @@ export default function VaultEditor(props: VaultEditorProps) {
</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
type="button"
className="btn btn-secondary small"
disabled={props.busy || removed || props.downloadingAttachmentKey === `${props.selectedCipher?.id || ''}:${attachmentId}`}
onClick={() => props.onDownloadAttachment(props.selectedCipher as Cipher, attachmentId)}
>
<Download size={14} className="btn-icon" /> {formatDownloadLabel(attachmentId)}
</button>
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={() => props.onToggleExistingAttachmentRemoval(attachmentId)}>
<X size={14} className="btn-icon" />
@@ -7,6 +7,7 @@ import {
ShieldUser,
StickyNote,
} from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import { t } from '@/lib/i18n';
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField } from '@/lib/types';
@@ -119,6 +120,10 @@ export function hostFromUri(uri: string): string {
}
}
export function websiteIconUrl(host: string): string {
return `/icons/${encodeURIComponent(host)}/icon.png`;
}
export function createEmptyDraft(type: number): VaultDraft {
return {
type,
@@ -294,9 +299,10 @@ export function VaultListIcon({ cipher }: { cipher: Cipher }) {
return (
<img
className="list-icon"
src={`/icons/${host}/icon.png?v=2`}
src={websiteIconUrl(host)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => {
failedIconHosts.add(host);
setErrored(true);
@@ -313,7 +319,7 @@ export function VaultListIcon({ cipher }: { cipher: Cipher }) {
export function copyToClipboard(value: string): void {
if (!value.trim()) return;
void navigator.clipboard.writeText(value);
void copyTextToClipboard(value);
}
export function openUri(raw: string): void {