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
+16 -7
View File
@@ -37,20 +37,29 @@ function twoFactorRequiredResponse(message: string = 'Two factor required.', inc
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
const providers2: Record<string, null> = {};
for (const provider of providers) providers2[provider] = null;
const customResponse = {
TwoFactorProviders: providers,
TwoFactorProviders2: providers2,
SsoEmail2faSessionToken: null,
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
};
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
return jsonResponse(
{
error: 'invalid_grant',
error_description: message,
TwoFactorProviders: providers,
TwoFactorProviders2: providers2,
Error: 'invalid_grant',
ErrorDescription: message,
ErrorMessage: message,
TwoFactorProviders: customResponse.TwoFactorProviders,
TwoFactorProviders2: customResponse.TwoFactorProviders2,
// Required by current Android parser (nullable value is acceptable).
SsoEmail2faSessionToken: null,
// Keep payload shape close to upstream implementations.
MasterPasswordPolicy: {
Object: 'masterPasswordPolicy',
},
SsoEmail2faSessionToken: customResponse.SsoEmail2faSessionToken,
MasterPasswordPolicy: customResponse.MasterPasswordPolicy,
CustomResponse: customResponse,
ErrorModel: {
Message: message,
Object: 'error',
+5
View File
@@ -37,6 +37,7 @@ import {
handleGetSends,
handleGetSend,
handleCreateSend,
handleCreateFileSendV2,
handleGetSendFileUpload,
handleUploadSendFile,
handleUpdateSend,
@@ -217,6 +218,10 @@ export async function handleAuthenticatedRoute(
return null;
}
if (path === '/api/sends/file/v2' && method === 'POST') {
return handleCreateFileSendV2(request, env, userId);
}
if (path === '/api/sends/delete' && method === 'POST') {
return handleBulkDeleteSends(request, env, userId);
}
+41 -43
View File
@@ -57,68 +57,60 @@ function handleNwFavicon(): Response {
});
}
function isValidIconHostname(hostname: string): boolean {
if (!hostname) return false;
if (hostname.length > 253) return false;
const normalized = hostname.toLowerCase().replace(/\.$/, '');
const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/;
const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/;
if (domainPattern.test(normalized)) return true;
if (!ipv4Pattern.test(normalized)) return false;
const parts = normalized.split('.');
return parts.every((p) => {
const n = Number(p);
return Number.isInteger(n) && n >= 0 && n <= 255;
});
function buildIconServiceBase(origin: string): string {
return `${origin}/icons`;
}
async function handleGetIcon(env: Env, hostname: string): Promise<Response> {
function buildIconServiceTemplate(origin: string): string {
return `${buildIconServiceBase(origin)}/{}/icon.png`;
}
function buildIconServiceCsp(origin: string): string {
return `img-src 'self' data: ${origin}`;
}
function normalizeIconHost(rawHost: string): string | null {
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
try {
void env;
const normalizedHostname = hostname.toLowerCase();
if (!isValidIconHostname(normalizedHostname)) {
return new Response(null, { status: 204 });
}
const parsed = new URL(`https://${decoded}`);
return parsed.hostname === decoded ? decoded : null;
} catch {
return null;
}
}
const cache = caches.default;
const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' });
const cached = await cache.match(cacheKey);
if (cached) return cached;
async function handleWebsiteIcon(host: string): Promise<Response> {
const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return handleNwFavicon();
const resp = await fetch(`https://favicon.im/${normalizedHostname}`, {
headers: { 'User-Agent': 'NodeWarden/1.0' },
const upstream = `https://favicon.im/${encodeURIComponent(normalizedHost)}`;
try {
const resp = await fetch(upstream, {
redirect: 'follow',
cf: {
cacheEverything: true,
cacheTtl: LIMITS.cache.iconTtlSeconds,
},
});
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
if (!resp.ok) return new Response(null, { status: 204 });
if (!resp.ok) return handleNwFavicon();
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) return handleNwFavicon();
const body = await resp.arrayBuffer();
if (body.byteLength === 0) {
return new Response(null, { status: 204 });
}
const iconResponse = new Response(body, {
return new Response(resp.body, {
status: 200,
headers: {
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
},
});
await cache.put(cacheKey, iconResponse.clone());
return iconResponse;
} catch {
return new Response(null, { status: 204 });
return handleNwFavicon();
}
}
export function buildWebConfigResponse(env: Env) {
export function buildWebConfigResponse(env: Env, origin: string) {
const secret = (env.JWT_SECRET || '').trim();
const jwtUnsafeReason =
!secret
@@ -133,6 +125,9 @@ export function buildWebConfigResponse(env: Env) {
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
iconServiceUrl: buildIconServiceTemplate(origin),
};
}
@@ -152,7 +147,7 @@ export async function handlePublicRoute(
if (path === '/api/web/config' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return jsonResponse(buildWebConfigResponse(env));
return jsonResponse(buildWebConfigResponse(env, new URL(request.url).origin));
}
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
@@ -170,8 +165,8 @@ export async function handlePublicRoute(
}
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch) {
return handleGetIcon(env, iconMatch[1]);
if (iconMatch && method === 'GET') {
return handleWebsiteIcon(iconMatch[1]);
}
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
@@ -250,8 +245,11 @@ export async function handlePublicRoute(
api: origin + '/api',
identity: origin + '/identity',
notifications: origin + '/notifications',
icons: origin,
sso: '',
},
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
featureStates: {
'duo-redirect': true,
'email-verification': true,
+1 -1
View File
@@ -67,7 +67,7 @@ export function applyCors(
headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
headers.set('Content-Security-Policy', "frame-ancestors 'none'");
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cloudflareinsights.com https://*.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://icons.bitwarden.net https://cloudflareinsights.com https://*.cloudflareinsights.com; connect-src 'self' https://api.pwnedpasswords.com https://cloudflareinsights.com https://*.cloudflareinsights.com; font-src 'self'; form-action 'self'; base-uri 'self';" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cloudflareinsights.com https://*.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cloudflareinsights.com https://*.cloudflareinsights.com; connect-src 'self' https://api.pwnedpasswords.com https://cloudflareinsights.com https://*.cloudflareinsights.com; font-src 'self'; form-action 'self'; base-uri 'self';" />
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>NodeWarden</title>
+14
View File
@@ -46,6 +46,7 @@ import useBackupActions from '@/hooks/useBackupActions';
import useVaultSendActions from '@/hooks/useVaultSendActions';
import { useToastManager } from '@/hooks/useToastManager';
import { t } from '@/lib/i18n';
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
const IMPORT_ROUTE = '/help/import-export';
@@ -96,6 +97,17 @@ export default function App() {
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const { toasts, pushToast, removeToast } = useToastManager();
useEffect(() => {
const handleAppNotify = (event: Event) => {
const detail = (event as CustomEvent<AppNotifyDetail>).detail;
if (!detail?.text) return;
pushToast(detail.type, detail.text);
};
window.addEventListener(APP_NOTIFY_EVENT, handleAppNotify as EventListener);
return () => window.removeEventListener(APP_NOTIFY_EVENT, handleAppNotify as EventListener);
}, [pushToast]);
useEffect(() => {
const syncInviteFromUrl = () => {
setInviteCodeFromUrl(readInviteCodeFromUrl());
@@ -873,6 +885,8 @@ export default function App() {
onDeleteFolder: vaultSendActions.deleteFolder,
onBulkDeleteFolders: vaultSendActions.bulkDeleteFolders,
onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment,
downloadingAttachmentKey: vaultSendActions.downloadingAttachmentKey,
attachmentDownloadPercent: vaultSendActions.attachmentDownloadPercent,
onRefreshVault: vaultSendActions.refreshVault,
onCreateSend: vaultSendActions.createSend,
onUpdateSend: vaultSendActions.updateSend,
+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 {
+2 -2
View File
@@ -50,8 +50,8 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return listRemoteBackups(authedFetch, destinationId, path);
},
async downloadRemoteBackup(destinationId: string, path: string) {
const payload = await downloadRemoteBackup(authedFetch, destinationId, path);
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
const payload = await downloadRemoteBackup(authedFetch, destinationId, path, onProgress);
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
},
+14 -2
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'preact/hooks';
import { useMemo, useState } from 'preact/hooks';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
import {
@@ -91,6 +91,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
refetchSends,
onNotify,
} = options;
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
return useMemo(() => {
const refetchVault = async () => {
@@ -189,13 +191,19 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
async downloadVaultAttachment(cipher: Cipher, attachmentId: string) {
if (!session) return;
const downloadKey = `${cipher.id}:${attachmentId}`;
setDownloadingAttachmentKey(downloadKey);
setAttachmentDownloadPercent(null);
try {
const file = await downloadCipherAttachmentDecrypted(authedFetch, session, cipher, attachmentId);
const file = await downloadCipherAttachmentDecrypted(authedFetch, session, cipher, attachmentId, setAttachmentDownloadPercent);
const fileName = String(file.fileName || '').trim() || 'attachment.bin';
downloadBytesAsFile(file.bytes, fileName, 'application/octet-stream');
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_download_failed'));
throw error;
} finally {
setDownloadingAttachmentKey('');
setAttachmentDownloadPercent(null);
}
},
@@ -686,10 +694,14 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
if (!result) throw new Error(t('txt_unsupported_export_format'));
downloadBytesAsFile(result.bytes, result.fileName, result.mimeType);
},
downloadingAttachmentKey,
attachmentDownloadPercent,
};
}, [
attachmentDownloadPercent,
authedFetch,
defaultKdfIterations,
downloadingAttachmentKey,
encryptedCiphers,
encryptedFolders,
importAuthedFetch,
+4 -2
View File
@@ -15,6 +15,7 @@ import {
parseJson,
type AuthedFetch,
} from './shared';
import { readResponseBytesWithProgress } from '../download';
export type {
BackupDestinationConfig,
@@ -190,7 +191,8 @@ export async function listRemoteBackups(
export async function downloadRemoteBackup(
authedFetch: AuthedFetch,
destinationId: string,
path: string
path: string,
onProgress?: (percent: number | null) => void
): Promise<AdminBackupExportPayload> {
const params = new URLSearchParams();
params.set('destinationId', destinationId);
@@ -199,7 +201,7 @@ export async function downloadRemoteBackup(
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip');
const bytes = new Uint8Array(await resp.arrayBuffer());
const bytes = await readResponseBytesWithProgress(resp, (progress) => onProgress?.(progress.percent));
return { fileName, mimeType, bytes };
}
+4 -2
View File
@@ -14,6 +14,7 @@ import {
parseJson,
type AuthedFetch,
} from './shared';
import { readResponseBytesWithProgress } from '../download';
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
const resp = await authedFetch('/api/folders');
@@ -331,7 +332,8 @@ export async function downloadCipherAttachmentDecrypted(
authedFetch: AuthedFetch,
session: SessionState,
cipher: Cipher,
attachmentId: string
attachmentId: string,
onProgress?: (percent: number | null) => void
): Promise<{ fileName: string; bytes: Uint8Array }> {
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
const cid = String(cipher?.id || '').trim();
@@ -341,7 +343,7 @@ export async function downloadCipherAttachmentDecrypted(
const info = await getAttachmentDownloadInfo(authedFetch, cid, aid);
const rawResp = await fetch(info.url, { cache: 'no-store' });
if (!rawResp.ok) throw new Error('Download attachment failed');
const encryptedBytes = new Uint8Array(await rawResp.arrayBuffer());
const encryptedBytes = await readResponseBytesWithProgress(rawResp, (progress) => onProgress?.(progress.percent));
const userEnc = base64ToBytes(session.symEncKey);
const userMac = base64ToBytes(session.symMacKey);
+13
View File
@@ -0,0 +1,13 @@
export type AppNotifyType = 'success' | 'error' | 'warning';
export interface AppNotifyDetail {
type: AppNotifyType;
text: string;
}
export const APP_NOTIFY_EVENT = 'nodewarden:notify';
export function dispatchAppNotify(type: AppNotifyType, text: string): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent<AppNotifyDetail>(APP_NOTIFY_EVENT, { detail: { type, text } }));
}
+36
View File
@@ -0,0 +1,36 @@
import { dispatchAppNotify } from '@/lib/app-notify';
import { t } from '@/lib/i18n';
interface CopyTextOptions {
successMessage?: string;
errorMessage?: string;
emptyMessage?: string;
notify?: boolean;
onSuccess?: () => void;
onError?: () => void;
}
export async function copyTextToClipboard(value: string, options: CopyTextOptions = {}): Promise<boolean> {
const text = String(value || '');
if (!text.trim()) {
if (options.notify !== false) {
dispatchAppNotify('warning', options.emptyMessage || t('txt_nothing_to_copy'));
}
return false;
}
try {
await navigator.clipboard.writeText(text);
options.onSuccess?.();
if (options.notify !== false) {
dispatchAppNotify('success', options.successMessage || t('txt_copied'));
}
return true;
} catch {
options.onError?.();
if (options.notify !== false) {
dispatchAppNotify('error', options.errorMessage || t('txt_copy_failed'));
}
return false;
}
}
+58
View File
@@ -10,3 +10,61 @@ export function downloadBytesAsFile(bytes: Uint8Array, fileName: string, mimeTyp
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
}
export interface DownloadProgressState {
loaded: number;
total: number | null;
percent: number | null;
}
type ProgressCallback = (progress: DownloadProgressState) => void;
function parseContentLength(response: Response): number | null {
const raw = String(response.headers.get('Content-Length') || '').trim();
if (!raw) return null;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
export async function readResponseBytesWithProgress(
response: Response,
onProgress?: ProgressCallback
): Promise<Uint8Array> {
const total = parseContentLength(response);
const report = (loaded: number) => {
onProgress?.({
loaded,
total,
percent: total ? Math.max(0, Math.min(100, Math.round((loaded / total) * 100))) : null,
});
};
if (!response.body) {
const bytes = new Uint8Array(await response.arrayBuffer());
report(bytes.byteLength);
return bytes;
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let loaded = 0;
report(0);
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
chunks.push(value);
loaded += value.byteLength;
report(loaded);
}
const bytes = new Uint8Array(loaded);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
report(loaded);
return bytes;
}
+9 -1
View File
@@ -230,6 +230,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_change_password: "Change Password",
txt_change_password_failed: "Change password failed",
txt_change_password_confirm_and_sign_out_all_devices: "Changing the master password will sign out all devices, including this web session. Continue?",
txt_copy_failed: "Copy failed",
txt_checked: "Checked",
txt_choose_destination_folder: "Choose destination folder.",
txt_chrome_browser: "Chrome Browser",
@@ -289,6 +290,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_disable_totp: "Disable TOTP",
txt_disable_totp_failed: "Disable TOTP failed",
txt_download: "Download",
txt_downloading: "Downloading...",
txt_downloading_percent: "Downloading {percent}%",
txt_download_failed: "Download failed",
txt_edge_browser: "Edge Browser",
txt_edge_extension: "Edge Extension",
@@ -411,6 +414,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_name: "Name",
txt_name_is_required: "Name is required",
txt_new_password: "New Password",
txt_nothing_to_copy: "Nothing to copy",
txt_new_password_must_be_at_least_12_chars: "New password must be at least 12 chars",
txt_new_passwords_do_not_match: "New passwords do not match",
txt_new_send: "New Send",
@@ -852,8 +856,10 @@ const zhCNOverrides: Record<string, string> = {
txt_change_master_password: '修改主密码',
txt_current_password: '当前密码',
txt_new_password: '新密码',
txt_nothing_to_copy: '没有可复制的内容',
txt_change_password: '修改密码',
txt_change_password_confirm_and_sign_out_all_devices: '修改主密码后会强制退出所有设备,包括当前网页端。确认继续吗',
txt_copy_failed: '复制失败',
txt_totp: 'TOTP',
txt_enable_totp: '启用 TOTP',
txt_disable_totp: '停用 TOTP',
@@ -903,6 +909,8 @@ const zhCNOverrides: Record<string, string> = {
txt_nodewarden_send: 'NodeWarden 发送',
txt_send_unavailable: '发送不可用。',
txt_download: '下载',
txt_downloading: '下载中...',
txt_downloading_percent: '下载中 {percent}%',
txt_expires_at: '过期时间',
txt_expires_at_value: '过期于:{value}',
txt_dash: '-',
@@ -953,7 +961,7 @@ const zhCNOverrides: Record<string, string> = {
txt_remove_all_devices: '移除所有设备',
txt_remove_all_devices_and_clear_all_2fa_trust: '确认移除所有设备并清除全部 2FA 信任吗?',
txt_remove_all_devices_and_sign_out_all_sessions: '确认移除所有设备、清除全部信任,并让所有设备重新登录吗?',
txt_remove_device_and_sign_out_name: '确认移除设备“{name}”清除其信任,并让它重新登录吗?',
txt_remove_device_and_sign_out_name: '确认移除设备“{name}”清除其信任,并让它重新登录吗?',
txt_role_admin: '管理员',
txt_role_user: '用户',
txt_status_active: '正常',
+3
View File
@@ -264,6 +264,9 @@ export interface WebConfigResponse {
defaultKdfIterations?: number;
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null;
jwtSecretMinLength?: number;
_icon_service_url?: string;
_icon_service_csp?: string;
iconServiceUrl?: string;
}
export interface TokenSuccess {