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:
@@ -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',
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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