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)];
|
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
|
||||||
const providers2: Record<string, null> = {};
|
const providers2: Record<string, null> = {};
|
||||||
for (const provider of providers) providers2[provider] = 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.
|
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
error: 'invalid_grant',
|
error: 'invalid_grant',
|
||||||
error_description: message,
|
error_description: message,
|
||||||
TwoFactorProviders: providers,
|
Error: 'invalid_grant',
|
||||||
TwoFactorProviders2: providers2,
|
ErrorDescription: message,
|
||||||
|
ErrorMessage: message,
|
||||||
|
TwoFactorProviders: customResponse.TwoFactorProviders,
|
||||||
|
TwoFactorProviders2: customResponse.TwoFactorProviders2,
|
||||||
// Required by current Android parser (nullable value is acceptable).
|
// Required by current Android parser (nullable value is acceptable).
|
||||||
SsoEmail2faSessionToken: null,
|
SsoEmail2faSessionToken: customResponse.SsoEmail2faSessionToken,
|
||||||
// Keep payload shape close to upstream implementations.
|
MasterPasswordPolicy: customResponse.MasterPasswordPolicy,
|
||||||
MasterPasswordPolicy: {
|
CustomResponse: customResponse,
|
||||||
Object: 'masterPasswordPolicy',
|
|
||||||
},
|
|
||||||
ErrorModel: {
|
ErrorModel: {
|
||||||
Message: message,
|
Message: message,
|
||||||
Object: 'error',
|
Object: 'error',
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
handleGetSends,
|
handleGetSends,
|
||||||
handleGetSend,
|
handleGetSend,
|
||||||
handleCreateSend,
|
handleCreateSend,
|
||||||
|
handleCreateFileSendV2,
|
||||||
handleGetSendFileUpload,
|
handleGetSendFileUpload,
|
||||||
handleUploadSendFile,
|
handleUploadSendFile,
|
||||||
handleUpdateSend,
|
handleUpdateSend,
|
||||||
@@ -217,6 +218,10 @@ export async function handleAuthenticatedRoute(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/api/sends/file/v2' && method === 'POST') {
|
||||||
|
return handleCreateFileSendV2(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/sends/delete' && method === 'POST') {
|
if (path === '/api/sends/delete' && method === 'POST') {
|
||||||
return handleBulkDeleteSends(request, env, userId);
|
return handleBulkDeleteSends(request, env, userId);
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-42
@@ -57,68 +57,60 @@ function handleNwFavicon(): Response {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidIconHostname(hostname: string): boolean {
|
function buildIconServiceBase(origin: string): string {
|
||||||
if (!hostname) return false;
|
return `${origin}/icons`;
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
void env;
|
const parsed = new URL(`https://${decoded}`);
|
||||||
const normalizedHostname = hostname.toLowerCase();
|
return parsed.hostname === decoded ? decoded : null;
|
||||||
if (!isValidIconHostname(normalizedHostname)) {
|
} catch {
|
||||||
return new Response(null, { status: 204 });
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const cache = caches.default;
|
async function handleWebsiteIcon(host: string): Promise<Response> {
|
||||||
const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' });
|
const normalizedHost = normalizeIconHost(host);
|
||||||
const cached = await cache.match(cacheKey);
|
if (!normalizedHost) return handleNwFavicon();
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const resp = await fetch(`https://favicon.im/${normalizedHostname}`, {
|
const upstream = `https://favicon.im/${encodeURIComponent(normalizedHost)}`;
|
||||||
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
try {
|
||||||
|
const resp = await fetch(upstream, {
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
cf: {
|
cf: {
|
||||||
cacheEverything: true,
|
cacheEverything: true,
|
||||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
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();
|
return new Response(resp.body, {
|
||||||
if (body.byteLength === 0) {
|
|
||||||
return new Response(null, { status: 204 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconResponse = new Response(body, {
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await cache.put(cacheKey, iconResponse.clone());
|
|
||||||
return iconResponse;
|
|
||||||
} catch {
|
} 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 secret = (env.JWT_SECRET || '').trim();
|
||||||
const jwtUnsafeReason =
|
const jwtUnsafeReason =
|
||||||
!secret
|
!secret
|
||||||
@@ -133,6 +125,9 @@ export function buildWebConfigResponse(env: Env) {
|
|||||||
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||||
jwtUnsafeReason,
|
jwtUnsafeReason,
|
||||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
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') {
|
if (path === '/api/web/config' && method === 'GET') {
|
||||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
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') {
|
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);
|
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||||
if (iconMatch) {
|
if (iconMatch && method === 'GET') {
|
||||||
return handleGetIcon(env, iconMatch[1]);
|
return handleWebsiteIcon(iconMatch[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||||
@@ -250,8 +245,11 @@ export async function handlePublicRoute(
|
|||||||
api: origin + '/api',
|
api: origin + '/api',
|
||||||
identity: origin + '/identity',
|
identity: origin + '/identity',
|
||||||
notifications: origin + '/notifications',
|
notifications: origin + '/notifications',
|
||||||
|
icons: origin,
|
||||||
sso: '',
|
sso: '',
|
||||||
},
|
},
|
||||||
|
_icon_service_url: buildIconServiceTemplate(origin),
|
||||||
|
_icon_service_csp: buildIconServiceCsp(origin),
|
||||||
featureStates: {
|
featureStates: {
|
||||||
'duo-redirect': true,
|
'duo-redirect': true,
|
||||||
'email-verification': true,
|
'email-verification': true,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function applyCors(
|
|||||||
headers.set('X-Frame-Options', 'DENY');
|
headers.set('X-Frame-Options', 'DENY');
|
||||||
headers.set('X-Content-Type-Options', 'nosniff');
|
headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
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, {
|
return new Response(response.body, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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="icon" type="image/png" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<title>NodeWarden</title>
|
<title>NodeWarden</title>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import useBackupActions from '@/hooks/useBackupActions';
|
|||||||
import useVaultSendActions from '@/hooks/useVaultSendActions';
|
import useVaultSendActions from '@/hooks/useVaultSendActions';
|
||||||
import { useToastManager } from '@/hooks/useToastManager';
|
import { useToastManager } from '@/hooks/useToastManager';
|
||||||
import { t } from '@/lib/i18n';
|
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';
|
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||||
|
|
||||||
const IMPORT_ROUTE = '/help/import-export';
|
const IMPORT_ROUTE = '/help/import-export';
|
||||||
@@ -96,6 +97,17 @@ export default function App() {
|
|||||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const { toasts, pushToast, removeToast } = useToastManager();
|
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(() => {
|
useEffect(() => {
|
||||||
const syncInviteFromUrl = () => {
|
const syncInviteFromUrl = () => {
|
||||||
setInviteCodeFromUrl(readInviteCodeFromUrl());
|
setInviteCodeFromUrl(readInviteCodeFromUrl());
|
||||||
@@ -873,6 +885,8 @@ export default function App() {
|
|||||||
onDeleteFolder: vaultSendActions.deleteFolder,
|
onDeleteFolder: vaultSendActions.deleteFolder,
|
||||||
onBulkDeleteFolders: vaultSendActions.bulkDeleteFolders,
|
onBulkDeleteFolders: vaultSendActions.bulkDeleteFolders,
|
||||||
onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment,
|
onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment,
|
||||||
|
downloadingAttachmentKey: vaultSendActions.downloadingAttachmentKey,
|
||||||
|
attachmentDownloadPercent: vaultSendActions.attachmentDownloadPercent,
|
||||||
onRefreshVault: vaultSendActions.refreshVault,
|
onRefreshVault: vaultSendActions.refreshVault,
|
||||||
onCreateSend: vaultSendActions.createSend,
|
onCreateSend: vaultSendActions.createSend,
|
||||||
onUpdateSend: vaultSendActions.updateSend,
|
onUpdateSend: vaultSendActions.updateSend,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
|
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 type { AdminInvite, AdminUser } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
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')}
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
|||||||
>
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_totp_code')}</span>
|
<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>
|
||||||
<label className="check-line" style={{ marginBottom: 0 }}>
|
<label className="check-line" style={{ marginBottom: 0 }}>
|
||||||
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
<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">
|
<label className="field">
|
||||||
<span>{t('txt_master_password')}</span>
|
<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>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export interface AppMainRoutesProps {
|
|||||||
onDeleteFolder: (folderId: string) => Promise<void>;
|
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||||
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
||||||
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||||
|
downloadingAttachmentKey: string;
|
||||||
|
attachmentDownloadPercent: number | null;
|
||||||
onRefreshVault: () => Promise<void>;
|
onRefreshVault: () => Promise<void>;
|
||||||
onCreateSend: (draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
onCreateSend: (draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||||
onUpdateSend: (send: Send, 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>;
|
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
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>;
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<void>;
|
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -167,6 +169,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onDeleteFolder={props.onDeleteFolder}
|
onDeleteFolder={props.onDeleteFolder}
|
||||||
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||||
onDownloadAttachment={props.onDownloadVaultAttachment}
|
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||||
|
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||||
|
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ function PasswordField(props: {
|
|||||||
value: string;
|
value: string;
|
||||||
onInput: (v: string) => void;
|
onInput: (v: string) => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
autoComplete?: string;
|
||||||
}) {
|
}) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -51,6 +52,7 @@ function PasswordField(props: {
|
|||||||
value={props.value}
|
value={props.value}
|
||||||
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
|
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
|
||||||
autoFocus={props.autoFocus}
|
autoFocus={props.autoFocus}
|
||||||
|
autoComplete={props.autoComplete}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
|
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
|
||||||
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
{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>
|
<p className="muted standalone-muted">{props.emailForLock}</p>
|
||||||
|
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
|
||||||
<PasswordField
|
<PasswordField
|
||||||
label={t('txt_master_password')}
|
label={t('txt_master_password')}
|
||||||
value={props.unlockPassword}
|
value={props.unlockPassword}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
autoComplete="current-password"
|
||||||
onInput={props.onChangeUnlock}
|
onInput={props.onChangeUnlock}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="btn btn-primary full" disabled={unlockBusy}>
|
<button type="submit" className="btn btn-primary full" disabled={unlockBusy}>
|
||||||
@@ -112,6 +116,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={props.registerValues.name}
|
value={props.registerValues.name}
|
||||||
|
autoComplete="name"
|
||||||
onInput={(e) =>
|
onInput={(e) =>
|
||||||
props.onChangeRegister({ ...props.registerValues, name: (e.currentTarget as HTMLInputElement).value })
|
props.onChangeRegister({ ...props.registerValues, name: (e.currentTarget as HTMLInputElement).value })
|
||||||
}
|
}
|
||||||
@@ -123,6 +128,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
className="input"
|
className="input"
|
||||||
type="email"
|
type="email"
|
||||||
value={props.registerValues.email}
|
value={props.registerValues.email}
|
||||||
|
autoComplete="email"
|
||||||
onInput={(e) =>
|
onInput={(e) =>
|
||||||
props.onChangeRegister({ ...props.registerValues, email: (e.currentTarget as HTMLInputElement).value })
|
props.onChangeRegister({ ...props.registerValues, email: (e.currentTarget as HTMLInputElement).value })
|
||||||
}
|
}
|
||||||
@@ -131,11 +137,13 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
<PasswordField
|
<PasswordField
|
||||||
label={t('txt_master_password')}
|
label={t('txt_master_password')}
|
||||||
value={props.registerValues.password}
|
value={props.registerValues.password}
|
||||||
|
autoComplete="new-password"
|
||||||
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password: v })}
|
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password: v })}
|
||||||
/>
|
/>
|
||||||
<PasswordField
|
<PasswordField
|
||||||
label={t('txt_confirm_master_password')}
|
label={t('txt_confirm_master_password')}
|
||||||
value={props.registerValues.password2}
|
value={props.registerValues.password2}
|
||||||
|
autoComplete="new-password"
|
||||||
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
|
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
|
||||||
/>
|
/>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
@@ -143,6 +151,7 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={props.registerValues.inviteCode}
|
value={props.registerValues.inviteCode}
|
||||||
|
autoComplete="off"
|
||||||
onInput={(e) =>
|
onInput={(e) =>
|
||||||
props.onChangeRegister({ ...props.registerValues, inviteCode: (e.currentTarget as HTMLInputElement).value })
|
props.onChangeRegister({ ...props.registerValues, inviteCode: (e.currentTarget as HTMLInputElement).value })
|
||||||
}
|
}
|
||||||
@@ -178,12 +187,14 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
className="input"
|
className="input"
|
||||||
type="email"
|
type="email"
|
||||||
value={props.loginValues.email}
|
value={props.loginValues.email}
|
||||||
|
autoComplete="username"
|
||||||
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
|
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<PasswordField
|
<PasswordField
|
||||||
label={t('txt_master_password')}
|
label={t('txt_master_password')}
|
||||||
value={props.loginValues.password}
|
value={props.loginValues.password}
|
||||||
|
autoComplete="current-password"
|
||||||
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
|
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ interface BackupCenterPageProps {
|
|||||||
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
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>;
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<void>;
|
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<void>;
|
||||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
@@ -54,6 +54,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const [runningRemoteBackup, setRunningRemoteBackup] = useState(false);
|
const [runningRemoteBackup, setRunningRemoteBackup] = useState(false);
|
||||||
const [loadingRemoteBrowser, setLoadingRemoteBrowser] = useState(false);
|
const [loadingRemoteBrowser, setLoadingRemoteBrowser] = useState(false);
|
||||||
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
|
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
|
||||||
|
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
||||||
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
||||||
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
||||||
const [localError, setLocalError] = useState('');
|
const [localError, setLocalError] = useState('');
|
||||||
@@ -367,15 +368,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
async function handleDownloadRemote(path: string) {
|
async function handleDownloadRemote(path: string) {
|
||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
setDownloadingRemotePath(path);
|
setDownloadingRemotePath(path);
|
||||||
|
setDownloadingRemotePercent(null);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path);
|
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
props.onNotify('error', message);
|
props.onNotify('error', message);
|
||||||
} finally {
|
} finally {
|
||||||
setDownloadingRemotePath('');
|
setDownloadingRemotePath('');
|
||||||
|
setDownloadingRemotePercent(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +482,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
remoteBrowserTotalPages={remoteBrowserTotalPages}
|
remoteBrowserTotalPages={remoteBrowserTotalPages}
|
||||||
loadingRemoteBrowser={loadingRemoteBrowser}
|
loadingRemoteBrowser={loadingRemoteBrowser}
|
||||||
downloadingRemotePath={downloadingRemotePath}
|
downloadingRemotePath={downloadingRemotePath}
|
||||||
|
downloadingRemotePercent={downloadingRemotePercent}
|
||||||
restoringRemotePath={restoringRemotePath}
|
restoringRemotePath={restoringRemotePath}
|
||||||
deletingRemotePath={deletingRemotePath}
|
deletingRemotePath={deletingRemotePath}
|
||||||
onSaveSettings={() => void handleSaveSettings()}
|
onSaveSettings={() => void handleSaveSettings()}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type { CiphersImportPayload } from '@/lib/api/vault';
|
|||||||
import {
|
import {
|
||||||
type EncryptedJsonMode,
|
type EncryptedJsonMode,
|
||||||
EXPORT_FORMATS,
|
EXPORT_FORMATS,
|
||||||
type ExportDownloadPayload,
|
|
||||||
type ExportFormatId,
|
type ExportFormatId,
|
||||||
type ExportRequest,
|
type ExportRequest,
|
||||||
} from '@/lib/export-formats';
|
} from '@/lib/export-formats';
|
||||||
@@ -48,7 +47,7 @@ interface ImportPageProps {
|
|||||||
accountKeys?: { encB64: string; macB64: string } | null;
|
accountKeys?: { encB64: string; macB64: string } | null;
|
||||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
onExport: (request: ExportRequest) => Promise<ExportDownloadPayload>;
|
onExport: (request: ExportRequest) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportResultSummary {
|
export interface ImportResultSummary {
|
||||||
@@ -539,23 +538,13 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
|
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
try {
|
try {
|
||||||
const payload = await onExport({
|
await onExport({
|
||||||
format: exportFormat,
|
format: exportFormat,
|
||||||
encryptedJsonMode: exportNeedsMode ? encryptedJsonMode : undefined,
|
encryptedJsonMode: exportNeedsMode ? encryptedJsonMode : undefined,
|
||||||
filePassword,
|
filePassword,
|
||||||
zipPassword: exportIsZip ? zipPass : '',
|
zipPassword: exportIsZip ? zipPass : '',
|
||||||
masterPassword,
|
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'));
|
onNotify('success', t('txt_export_completed'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_export_failed');
|
const message = error instanceof Error ? error.message : t('txt_export_failed');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from 'preact/hooks';
|
import { useMemo, useState } from 'preact/hooks';
|
||||||
import { AlertTriangle, Copy, RefreshCw } from 'lucide-preact';
|
import { AlertTriangle, Copy, RefreshCw } from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -55,8 +56,10 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await navigator.clipboard.writeText(generatedSecret);
|
await copyTextToClipboard(generatedSecret, {
|
||||||
setCopyHint(t('txt_copied'));
|
onSuccess: () => setCopyHint(t('txt_copied')),
|
||||||
|
onError: () => setCopyHint(t('txt_copy_failed')),
|
||||||
|
});
|
||||||
window.setTimeout(() => setCopyHint(''), 1500);
|
window.setTimeout(() => setCopyHint(''), 1500);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import { Download, Eye, Lock } from 'lucide-preact';
|
import { Download, Eye, Lock } from 'lucide-preact';
|
||||||
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
||||||
|
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
||||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [sendData, setSendData] = useState<any>(null);
|
const [sendData, setSendData] = useState<any>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
|
||||||
|
|
||||||
async function loadSend(pass?: string): Promise<void> {
|
async function loadSend(pass?: string): Promise<void> {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -48,12 +50,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
async function downloadFile(): Promise<void> {
|
async function downloadFile(): Promise<void> {
|
||||||
if (!sendData?.id || !sendData?.file?.id) return;
|
if (!sendData?.id || !sendData?.file?.id) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
setDownloadPercent(null);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
|
const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
|
||||||
const resp = await fetch(url);
|
const resp = await fetch(url);
|
||||||
if (!resp.ok) throw new Error(t('txt_download_failed'));
|
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;
|
let blob: Blob;
|
||||||
if (props.keyPart) {
|
if (props.keyPart) {
|
||||||
try {
|
try {
|
||||||
@@ -66,19 +69,17 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
} else {
|
} else {
|
||||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
||||||
}
|
}
|
||||||
const obj = URL.createObjectURL(blob);
|
downloadBytesAsFile(
|
||||||
const a = document.createElement('a');
|
new Uint8Array(await blob.arrayBuffer()),
|
||||||
a.href = obj;
|
sendData.decFileName || sendData.file?.fileName || t('txt_send_file'),
|
||||||
a.download = sendData.decFileName || sendData.file?.fileName || t('txt_send_file');
|
'application/octet-stream'
|
||||||
document.body.appendChild(a);
|
);
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(obj);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as Error;
|
const err = e as Error;
|
||||||
setError(err.message || t('txt_download_failed'));
|
setError(err.message || t('txt_download_failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
setDownloadPercent(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +106,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
className="input"
|
className="input"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
|
autoComplete="current-password"
|
||||||
onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}
|
onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void downloadFile()}>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
|
|||||||
className="input"
|
className="input"
|
||||||
type="email"
|
type="email"
|
||||||
value={props.values.email}
|
value={props.values.email}
|
||||||
|
autoComplete="username"
|
||||||
onInput={(e) => props.onChange({ ...props.values, email: (e.currentTarget as HTMLInputElement).value })}
|
onInput={(e) => props.onChange({ ...props.values, email: (e.currentTarget as HTMLInputElement).value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -41,6 +42,7 @@ export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
|
|||||||
className="input"
|
className="input"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={props.values.password}
|
value={props.values.password}
|
||||||
|
autoComplete="current-password"
|
||||||
onInput={(e) => props.onChange({ ...props.values, password: (e.currentTarget as HTMLInputElement).value })}
|
onInput={(e) => props.onChange({ ...props.values, password: (e.currentTarget as HTMLInputElement).value })}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="eye-btn" onClick={() => setShowPassword((v) => !v)}>
|
<button type="button" className="eye-btn" onClick={() => setShowPassword((v) => !v)}>
|
||||||
@@ -54,6 +56,7 @@ export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
|
|||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
value={props.values.recoveryCode}
|
value={props.values.recoveryCode}
|
||||||
|
autoComplete="one-time-code"
|
||||||
onInput={(e) => props.onChange({ ...props.values, recoveryCode: (e.currentTarget as HTMLInputElement).value.toUpperCase() })}
|
onInput={(e) => props.onChange({ ...props.values, recoveryCode: (e.currentTarget as HTMLInputElement).value.toUpperCase() })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
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 { 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 type { Send, SendDraft } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -211,8 +212,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
|
|
||||||
function copyAccessUrl(send: Send): void {
|
function copyAccessUrl(send: Send): void {
|
||||||
const url = send.shareUrl || `${window.location.origin}/#/send/${send.accessId}`;
|
const url = send.shareUrl || `${window.location.origin}/#/send/${send.accessId}`;
|
||||||
void navigator.clipboard.writeText(url);
|
void copyTextToClipboard(url, { successMessage: t('txt_link_copied') });
|
||||||
props.onNotify('success', t('txt_link_copied'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
|
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import qrcode from 'qrcode-generator';
|
import qrcode from 'qrcode-generator';
|
||||||
import type { Profile } from '@/lib/types';
|
import type { Profile } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -144,8 +145,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
disabled={totpLocked}
|
disabled={totpLocked}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard.writeText(secret);
|
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
|
||||||
props.onNotify?.('success', t('txt_secret_copied'));
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Clipboard size={14} className="btn-icon" />
|
<Clipboard size={14} className="btn-icon" />
|
||||||
@@ -185,8 +185,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
disabled={!recoveryCode}
|
disabled={!recoveryCode}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard.writeText(recoveryCode);
|
void copyTextToClipboard(recoveryCode, { successMessage: t('txt_recovery_code_copied') });
|
||||||
props.onNotify?.('success', t('txt_recovery_code_copied'));
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Clipboard size={14} className="btn-icon" />
|
<Clipboard size={14} className="btn-icon" />
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Clipboard, Globe } from 'lucide-preact';
|
import { Clipboard, Globe } from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
||||||
import { calcTotpNow } from '@/lib/crypto';
|
import { calcTotpNow } from '@/lib/crypto';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
|
import { websiteIconUrl } from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
interface TotpCodesPageProps {
|
interface TotpCodesPageProps {
|
||||||
ciphers: Cipher[];
|
ciphers: Cipher[];
|
||||||
@@ -13,6 +15,7 @@ interface TotpCodesPageProps {
|
|||||||
const TOTP_PERIOD_SECONDS = 30;
|
const TOTP_PERIOD_SECONDS = 30;
|
||||||
const TOTP_RING_RADIUS = 14;
|
const TOTP_RING_RADIUS = 14;
|
||||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
|
const failedIconHosts = new Set<string>();
|
||||||
|
|
||||||
function formatTotp(code: string): string {
|
function formatTotp(code: string): string {
|
||||||
if (!code || code.length < 6) return code;
|
if (!code || code.length < 6) return code;
|
||||||
@@ -41,20 +44,22 @@ function hostFromUri(uri: string): string {
|
|||||||
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
const uri = firstCipherUri(cipher);
|
const uri = firstCipherUri(cipher);
|
||||||
const host = hostFromUri(uri);
|
const host = hostFromUri(uri);
|
||||||
const [errored, setErrored] = useState(false);
|
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||||
|
|
||||||
if (host && !errored) {
|
if (host && !errored) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
className="list-icon"
|
className="list-icon"
|
||||||
src={`/icons/${host}/icon.png?v=2`}
|
src={websiteIconUrl(host)}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={() => setErrored(true)}
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() => {
|
||||||
|
failedIconHosts.add(host);
|
||||||
|
setErrored(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="list-icon-fallback">
|
<span className="list-icon-fallback">
|
||||||
<Globe size={18} />
|
<Globe size={18} />
|
||||||
@@ -68,9 +73,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
async function copyToClipboard(value: string): Promise<void> {
|
async function copyToClipboard(value: string): Promise<void> {
|
||||||
if (!value.trim()) return;
|
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
|
||||||
await navigator.clipboard.writeText(value);
|
|
||||||
props.onNotify('success', t('txt_code_copied'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const totpItems = useMemo(
|
const totpItems = useMemo(
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ interface VaultPageProps {
|
|||||||
onDeleteFolder: (folderId: string) => Promise<void>;
|
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||||
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
||||||
onDownloadAttachment: (cipher: Cipher, attachmentId: 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}
|
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
||||||
onRemoveQueuedAttachment={removeQueuedAttachment}
|
onRemoveQueuedAttachment={removeQueuedAttachment}
|
||||||
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
||||||
|
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||||
|
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||||
onPatchDraftCustomField={patchDraftCustomField}
|
onPatchDraftCustomField={patchDraftCustomField}
|
||||||
onUpdateDraftCustomFields={updateDraftCustomFields}
|
onUpdateDraftCustomFields={updateDraftCustomFields}
|
||||||
onOpenFieldModal={() => setFieldModalOpen(true)}
|
onOpenFieldModal={() => setFieldModalOpen(true)}
|
||||||
@@ -815,6 +819,8 @@ function folderName(id: string | null | undefined): string {
|
|||||||
onToggleShowPassword={() => setShowPassword((value) => !value)}
|
onToggleShowPassword={() => setShowPassword((value) => !value)}
|
||||||
onToggleHiddenField={(index) => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
|
onToggleHiddenField={(index) => setHiddenFieldVisibleMap((prev) => ({ ...prev, [index]: !prev[index] }))}
|
||||||
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
onDownloadAttachment={(cipher, attachmentId) => void props.onDownloadAttachment(cipher, attachmentId)}
|
||||||
|
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||||
|
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||||
onStartEdit={startEdit}
|
onStartEdit={startEdit}
|
||||||
onDelete={setPendingDelete}
|
onDelete={setPendingDelete}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface BackupDestinationDetailProps {
|
|||||||
remoteBrowserTotalPages: number;
|
remoteBrowserTotalPages: number;
|
||||||
loadingRemoteBrowser: boolean;
|
loadingRemoteBrowser: boolean;
|
||||||
downloadingRemotePath: string;
|
downloadingRemotePath: string;
|
||||||
|
downloadingRemotePercent: number | null;
|
||||||
restoringRemotePath: string;
|
restoringRemotePath: string;
|
||||||
deletingRemotePath: string;
|
deletingRemotePath: string;
|
||||||
onSaveSettings: () => void;
|
onSaveSettings: () => void;
|
||||||
@@ -511,6 +512,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
currentPage={props.remoteBrowserCurrentPage}
|
currentPage={props.remoteBrowserCurrentPage}
|
||||||
totalPages={props.remoteBrowserTotalPages}
|
totalPages={props.remoteBrowserTotalPages}
|
||||||
downloadingRemotePath={props.downloadingRemotePath}
|
downloadingRemotePath={props.downloadingRemotePath}
|
||||||
|
downloadingRemotePercent={props.downloadingRemotePercent}
|
||||||
restoringRemotePath={props.restoringRemotePath}
|
restoringRemotePath={props.restoringRemotePath}
|
||||||
deletingRemotePath={props.deletingRemotePath}
|
deletingRemotePath={props.deletingRemotePath}
|
||||||
onRefresh={props.onRefreshRemoteBrowser}
|
onRefresh={props.onRefreshRemoteBrowser}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface RemoteBackupBrowserProps {
|
|||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
downloadingRemotePath: string;
|
downloadingRemotePath: string;
|
||||||
|
downloadingRemotePercent: number | null;
|
||||||
restoringRemotePath: string;
|
restoringRemotePath: string;
|
||||||
deletingRemotePath: string;
|
deletingRemotePath: string;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
@@ -24,6 +25,13 @@ interface RemoteBackupBrowserProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RemoteBackupBrowser(props: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="backup-divider" />
|
<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)}>
|
<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" />
|
<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>
|
||||||
<button type="button" className="btn btn-primary small" disabled={props.disableWhileBusy || props.restoringRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onRestore(item.path)}>
|
<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" />
|
<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 { Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, Trash2 } from 'lucide-preact';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -22,6 +23,8 @@ interface VaultDetailViewProps {
|
|||||||
passkeyCreatedAt: string | null;
|
passkeyCreatedAt: string | null;
|
||||||
hiddenFieldVisibleMap: Record<number, boolean>;
|
hiddenFieldVisibleMap: Record<number, boolean>;
|
||||||
folderName: (id: string | null | undefined) => string;
|
folderName: (id: string | null | undefined) => string;
|
||||||
|
downloadingAttachmentKey: string;
|
||||||
|
attachmentDownloadPercent: number | null;
|
||||||
onOpenReprompt: () => void;
|
onOpenReprompt: () => void;
|
||||||
onToggleShowPassword: () => void;
|
onToggleShowPassword: () => void;
|
||||||
onToggleHiddenField: (index: number) => void;
|
onToggleHiddenField: (index: number) => void;
|
||||||
@@ -32,6 +35,14 @@ interface VaultDetailViewProps {
|
|||||||
|
|
||||||
export default function VaultDetailView(props: VaultDetailViewProps) {
|
export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||||
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -188,11 +199,22 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
<div className="kv-row">
|
<div className="kv-row">
|
||||||
<span className="kv-label">{t('txt_private_key')}</span>
|
<span className="kv-label">{t('txt_private_key')}</span>
|
||||||
<div className="kv-main">
|
<div className="kv-main">
|
||||||
<strong className="value-ellipsis" title={maskSecret(props.selectedCipher.sshKey.decPrivateKey || '')}>
|
<strong
|
||||||
{maskSecret(props.selectedCipher.sshKey.decPrivateKey || '')}
|
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>
|
</strong>
|
||||||
</div>
|
</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>
|
||||||
<div className="kv-row">
|
<div className="kv-row">
|
||||||
<span className="kv-label">{t('txt_public_key')}</span>
|
<span className="kv-label">{t('txt_public_key')}</span>
|
||||||
@@ -201,7 +223,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
{props.selectedCipher.sshKey.decPublicKey || ''}
|
{props.selectedCipher.sshKey.decPublicKey || ''}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</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>
|
||||||
<div className="kv-row">
|
<div className="kv-row">
|
||||||
<span className="kv-label">{t('txt_fingerprint')}</span>
|
<span className="kv-label">{t('txt_fingerprint')}</span>
|
||||||
@@ -210,7 +236,11 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
{props.selectedCipher.sshKey.decFingerprint || ''}
|
{props.selectedCipher.sshKey.decFingerprint || ''}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -292,8 +322,13 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv-actions">
|
<div className="kv-actions">
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onDownloadAttachment(props.selectedCipher, attachmentId)}>
|
<button
|
||||||
<Download size={14} className="btn-icon" /> {t('txt_download')}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface VaultEditorProps {
|
|||||||
attachmentQueue: File[];
|
attachmentQueue: File[];
|
||||||
attachmentInputRef: RefObject<HTMLInputElement>;
|
attachmentInputRef: RefObject<HTMLInputElement>;
|
||||||
localError: string;
|
localError: string;
|
||||||
|
downloadingAttachmentKey: string;
|
||||||
|
attachmentDownloadPercent: number | null;
|
||||||
onUpdateDraft: (patch: Partial<VaultDraft>) => void;
|
onUpdateDraft: (patch: Partial<VaultDraft>) => void;
|
||||||
onSeedSshDefaults: (force?: boolean) => void;
|
onSeedSshDefaults: (force?: boolean) => void;
|
||||||
onUpdateSshPublicKey: (value: string) => void;
|
onUpdateSshPublicKey: (value: string) => void;
|
||||||
@@ -33,6 +35,14 @@ interface VaultEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function VaultEditor(props: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -162,17 +172,32 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h4>{t('txt_ssh_key')}</h4>
|
<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')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_regenerate')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_private_key')}</span>
|
<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>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_public_key')}</span>
|
<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>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_fingerprint')}</span>
|
<span>{t('txt_fingerprint')}</span>
|
||||||
@@ -212,8 +237,13 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv-actions">
|
<div className="kv-actions">
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy || removed} onClick={() => props.onDownloadAttachment(props.selectedCipher as Cipher, attachmentId)}>
|
<button
|
||||||
<Download size={14} className="btn-icon" /> {t('txt_download')}
|
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>
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={() => props.onToggleExistingAttachmentRemoval(attachmentId)}>
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={() => props.onToggleExistingAttachmentRemoval(attachmentId)}>
|
||||||
<X size={14} className="btn-icon" />
|
<X size={14} className="btn-icon" />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ShieldUser,
|
ShieldUser,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
} from 'lucide-preact';
|
} from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField } from '@/lib/types';
|
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 {
|
export function createEmptyDraft(type: number): VaultDraft {
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
@@ -294,9 +299,10 @@ export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
className="list-icon"
|
className="list-icon"
|
||||||
src={`/icons/${host}/icon.png?v=2`}
|
src={websiteIconUrl(host)}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
onError={() => {
|
onError={() => {
|
||||||
failedIconHosts.add(host);
|
failedIconHosts.add(host);
|
||||||
setErrored(true);
|
setErrored(true);
|
||||||
@@ -313,7 +319,7 @@ export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
|
|
||||||
export function copyToClipboard(value: string): void {
|
export function copyToClipboard(value: string): void {
|
||||||
if (!value.trim()) return;
|
if (!value.trim()) return;
|
||||||
void navigator.clipboard.writeText(value);
|
void copyTextToClipboard(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openUri(raw: string): void {
|
export function openUri(raw: string): void {
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
return listRemoteBackups(authedFetch, destinationId, path);
|
return listRemoteBackups(authedFetch, destinationId, path);
|
||||||
},
|
},
|
||||||
|
|
||||||
async downloadRemoteBackup(destinationId: string, path: string) {
|
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
|
||||||
const payload = await downloadRemoteBackup(authedFetch, destinationId, path);
|
const payload = await downloadRemoteBackup(authedFetch, destinationId, path, onProgress);
|
||||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
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 { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
|
import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
|
||||||
import {
|
import {
|
||||||
@@ -91,6 +91,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
refetchSends,
|
refetchSends,
|
||||||
onNotify,
|
onNotify,
|
||||||
} = options;
|
} = options;
|
||||||
|
const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState('');
|
||||||
|
const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState<number | null>(null);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const refetchVault = async () => {
|
const refetchVault = async () => {
|
||||||
@@ -189,13 +191,19 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
|
|
||||||
async downloadVaultAttachment(cipher: Cipher, attachmentId: string) {
|
async downloadVaultAttachment(cipher: Cipher, attachmentId: string) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
const downloadKey = `${cipher.id}:${attachmentId}`;
|
||||||
|
setDownloadingAttachmentKey(downloadKey);
|
||||||
|
setAttachmentDownloadPercent(null);
|
||||||
try {
|
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';
|
const fileName = String(file.fileName || '').trim() || 'attachment.bin';
|
||||||
downloadBytesAsFile(file.bytes, fileName, 'application/octet-stream');
|
downloadBytesAsFile(file.bytes, fileName, 'application/octet-stream');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_download_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_download_failed'));
|
||||||
throw error;
|
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'));
|
if (!result) throw new Error(t('txt_unsupported_export_format'));
|
||||||
downloadBytesAsFile(result.bytes, result.fileName, result.mimeType);
|
downloadBytesAsFile(result.bytes, result.fileName, result.mimeType);
|
||||||
},
|
},
|
||||||
|
downloadingAttachmentKey,
|
||||||
|
attachmentDownloadPercent,
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
attachmentDownloadPercent,
|
||||||
authedFetch,
|
authedFetch,
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
|
downloadingAttachmentKey,
|
||||||
encryptedCiphers,
|
encryptedCiphers,
|
||||||
encryptedFolders,
|
encryptedFolders,
|
||||||
importAuthedFetch,
|
importAuthedFetch,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
parseJson,
|
parseJson,
|
||||||
type AuthedFetch,
|
type AuthedFetch,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { readResponseBytesWithProgress } from '../download';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
BackupDestinationConfig,
|
BackupDestinationConfig,
|
||||||
@@ -190,7 +191,8 @@ export async function listRemoteBackups(
|
|||||||
export async function downloadRemoteBackup(
|
export async function downloadRemoteBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
destinationId: string,
|
destinationId: string,
|
||||||
path: string
|
path: string,
|
||||||
|
onProgress?: (percent: number | null) => void
|
||||||
): Promise<AdminBackupExportPayload> {
|
): Promise<AdminBackupExportPayload> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('destinationId', destinationId);
|
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')));
|
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 mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
|
||||||
const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.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 };
|
return { fileName, mimeType, bytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
parseJson,
|
parseJson,
|
||||||
type AuthedFetch,
|
type AuthedFetch,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { readResponseBytesWithProgress } from '../download';
|
||||||
|
|
||||||
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
|
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
|
||||||
const resp = await authedFetch('/api/folders');
|
const resp = await authedFetch('/api/folders');
|
||||||
@@ -331,7 +332,8 @@ export async function downloadCipherAttachmentDecrypted(
|
|||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
session: SessionState,
|
session: SessionState,
|
||||||
cipher: Cipher,
|
cipher: Cipher,
|
||||||
attachmentId: string
|
attachmentId: string,
|
||||||
|
onProgress?: (percent: number | null) => void
|
||||||
): Promise<{ fileName: string; bytes: Uint8Array }> {
|
): Promise<{ fileName: string; bytes: Uint8Array }> {
|
||||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||||
const cid = String(cipher?.id || '').trim();
|
const cid = String(cipher?.id || '').trim();
|
||||||
@@ -341,7 +343,7 @@ export async function downloadCipherAttachmentDecrypted(
|
|||||||
const info = await getAttachmentDownloadInfo(authedFetch, cid, aid);
|
const info = await getAttachmentDownloadInfo(authedFetch, cid, aid);
|
||||||
const rawResp = await fetch(info.url, { cache: 'no-store' });
|
const rawResp = await fetch(info.url, { cache: 'no-store' });
|
||||||
if (!rawResp.ok) throw new Error('Download attachment failed');
|
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 userEnc = base64ToBytes(session.symEncKey);
|
||||||
const userMac = base64ToBytes(session.symMacKey);
|
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();
|
anchor.remove();
|
||||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
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: "Change Password",
|
||||||
txt_change_password_failed: "Change password failed",
|
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_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_checked: "Checked",
|
||||||
txt_choose_destination_folder: "Choose destination folder.",
|
txt_choose_destination_folder: "Choose destination folder.",
|
||||||
txt_chrome_browser: "Chrome Browser",
|
txt_chrome_browser: "Chrome Browser",
|
||||||
@@ -289,6 +290,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_disable_totp: "Disable TOTP",
|
txt_disable_totp: "Disable TOTP",
|
||||||
txt_disable_totp_failed: "Disable TOTP failed",
|
txt_disable_totp_failed: "Disable TOTP failed",
|
||||||
txt_download: "Download",
|
txt_download: "Download",
|
||||||
|
txt_downloading: "Downloading...",
|
||||||
|
txt_downloading_percent: "Downloading {percent}%",
|
||||||
txt_download_failed: "Download failed",
|
txt_download_failed: "Download failed",
|
||||||
txt_edge_browser: "Edge Browser",
|
txt_edge_browser: "Edge Browser",
|
||||||
txt_edge_extension: "Edge Extension",
|
txt_edge_extension: "Edge Extension",
|
||||||
@@ -411,6 +414,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_name: "Name",
|
txt_name: "Name",
|
||||||
txt_name_is_required: "Name is required",
|
txt_name_is_required: "Name is required",
|
||||||
txt_new_password: "New Password",
|
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_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_passwords_do_not_match: "New passwords do not match",
|
||||||
txt_new_send: "New Send",
|
txt_new_send: "New Send",
|
||||||
@@ -852,8 +856,10 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_change_master_password: '修改主密码',
|
txt_change_master_password: '修改主密码',
|
||||||
txt_current_password: '当前密码',
|
txt_current_password: '当前密码',
|
||||||
txt_new_password: '新密码',
|
txt_new_password: '新密码',
|
||||||
|
txt_nothing_to_copy: '没有可复制的内容',
|
||||||
txt_change_password: '修改密码',
|
txt_change_password: '修改密码',
|
||||||
txt_change_password_confirm_and_sign_out_all_devices: '修改主密码后会强制退出所有设备,包括当前网页端。确认继续吗',
|
txt_change_password_confirm_and_sign_out_all_devices: '修改主密码后会强制退出所有设备,包括当前网页端。确认继续吗',
|
||||||
|
txt_copy_failed: '复制失败',
|
||||||
txt_totp: 'TOTP',
|
txt_totp: 'TOTP',
|
||||||
txt_enable_totp: '启用 TOTP',
|
txt_enable_totp: '启用 TOTP',
|
||||||
txt_disable_totp: '停用 TOTP',
|
txt_disable_totp: '停用 TOTP',
|
||||||
@@ -903,6 +909,8 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_nodewarden_send: 'NodeWarden 发送',
|
txt_nodewarden_send: 'NodeWarden 发送',
|
||||||
txt_send_unavailable: '发送不可用。',
|
txt_send_unavailable: '发送不可用。',
|
||||||
txt_download: '下载',
|
txt_download: '下载',
|
||||||
|
txt_downloading: '下载中...',
|
||||||
|
txt_downloading_percent: '下载中 {percent}%',
|
||||||
txt_expires_at: '过期时间',
|
txt_expires_at: '过期时间',
|
||||||
txt_expires_at_value: '过期于:{value}',
|
txt_expires_at_value: '过期于:{value}',
|
||||||
txt_dash: '-',
|
txt_dash: '-',
|
||||||
@@ -953,7 +961,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_remove_all_devices: '移除所有设备',
|
txt_remove_all_devices: '移除所有设备',
|
||||||
txt_remove_all_devices_and_clear_all_2fa_trust: '确认移除所有设备并清除全部 2FA 信任吗?',
|
txt_remove_all_devices_and_clear_all_2fa_trust: '确认移除所有设备并清除全部 2FA 信任吗?',
|
||||||
txt_remove_all_devices_and_sign_out_all_sessions: '确认移除所有设备、清除全部信任,并让所有设备重新登录吗?',
|
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_admin: '管理员',
|
||||||
txt_role_user: '用户',
|
txt_role_user: '用户',
|
||||||
txt_status_active: '正常',
|
txt_status_active: '正常',
|
||||||
|
|||||||
@@ -264,6 +264,9 @@ export interface WebConfigResponse {
|
|||||||
defaultKdfIterations?: number;
|
defaultKdfIterations?: number;
|
||||||
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null;
|
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null;
|
||||||
jwtSecretMinLength?: number;
|
jwtSecretMinLength?: number;
|
||||||
|
_icon_service_url?: string;
|
||||||
|
_icon_service_csp?: string;
|
||||||
|
iconServiceUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenSuccess {
|
export interface TokenSuccess {
|
||||||
|
|||||||
Reference in New Issue
Block a user