mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } }));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: '正常',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user