mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance backup progress handling and improve user status toggling
This commit is contained in:
+12
-11
@@ -53,6 +53,17 @@ import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||
|
||||
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const detail = value as Record<string, unknown>;
|
||||
const operation = detail.operation;
|
||||
return (
|
||||
(operation === 'backup-restore' || operation === 'backup-export' || operation === 'backup-remote-run')
|
||||
&& typeof detail.step === 'string'
|
||||
&& typeof detail.fileName === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
const IMPORT_ROUTE = '/backup/import-export';
|
||||
const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
||||
@@ -927,17 +938,7 @@ export default function App() {
|
||||
}
|
||||
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
|
||||
const payload = frame.arguments?.[0]?.Payload;
|
||||
if (
|
||||
payload
|
||||
&& typeof payload === 'object'
|
||||
&& (
|
||||
payload.operation === 'backup-restore'
|
||||
|| payload.operation === 'backup-export'
|
||||
|| payload.operation === 'backup-remote-run'
|
||||
)
|
||||
) {
|
||||
dispatchBackupProgress(payload as BackupProgressDetail);
|
||||
}
|
||||
if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload);
|
||||
continue;
|
||||
}
|
||||
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
||||
|
||||
@@ -40,6 +40,12 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
return status || '-';
|
||||
};
|
||||
|
||||
const normalizeToggleableStatus = (status: string): 'active' | 'banned' | null => {
|
||||
const normalized = String(status || '').toLowerCase();
|
||||
if (normalized === 'active' || normalized === 'banned') return normalized;
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
@@ -55,8 +61,10 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{props.users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
{props.users.map((user) => {
|
||||
const toggleableStatus = normalizeToggleableStatus(user.status);
|
||||
return (
|
||||
<tr key={user.id}>
|
||||
<td data-label={t('txt_email')}>{user.email}</td>
|
||||
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
||||
<td data-label={t('txt_role')}>{roleText(user.role)}</td>
|
||||
@@ -66,8 +74,11 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={user.id === props.currentUserId}
|
||||
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
|
||||
disabled={user.id === props.currentUserId || !toggleableStatus}
|
||||
onClick={() => {
|
||||
if (!toggleableStatus) return;
|
||||
void props.onToggleUserStatus(user.id, toggleableStatus);
|
||||
}}
|
||||
>
|
||||
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
|
||||
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
|
||||
@@ -80,8 +91,9 @@ export default function AdminPage(props: AdminPageProps) {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -625,7 +625,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
setSettings(result.settings);
|
||||
setSelectedDestinationId(selectedDestination.id);
|
||||
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
||||
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.fileName }));
|
||||
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
||||
setLocalError(message);
|
||||
|
||||
@@ -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 { toBufferSource } from '@/lib/crypto';
|
||||
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -61,13 +62,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
||||
if (props.keyPart) {
|
||||
try {
|
||||
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
|
||||
blob = new Blob([decryptedBytes as unknown as BlobPart], { type: 'application/octet-stream' });
|
||||
blob = new Blob([toBufferSource(decryptedBytes)], { type: 'application/octet-stream' });
|
||||
} catch {
|
||||
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
|
||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
||||
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||
}
|
||||
} else {
|
||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
||||
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||
}
|
||||
downloadBytesAsFile(
|
||||
new Uint8Array(await blob.arrayBuffer()),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JSX } from 'preact';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
|
||||
import {
|
||||
@@ -96,6 +97,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.cipher.id,
|
||||
});
|
||||
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -113,7 +115,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
||||
className="btn btn-secondary small totp-drag-btn"
|
||||
title={t('txt_drag_to_reorder')}
|
||||
aria-label={t('txt_drag_to_reorder')}
|
||||
{...attributes}
|
||||
{...dragButtonAttributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} className="btn-icon" />
|
||||
|
||||
@@ -134,6 +134,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
...COMMON_TIME_ZONES,
|
||||
...props.availableTimeZones,
|
||||
]));
|
||||
const selectedIntervalHours = props.selectedDestination?.schedule.intervalHours ?? 24;
|
||||
|
||||
if (props.selectedRecommendedProvider) {
|
||||
return (
|
||||
@@ -216,7 +217,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={String(props.selectedDestination.schedule.intervalHours || 24)}
|
||||
value={String(selectedIntervalHours)}
|
||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||
onInput={(event) => {
|
||||
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
||||
@@ -234,7 +235,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
</div>
|
||||
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
||||
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
||||
const active = preset === props.selectedDestination.schedule.intervalHours;
|
||||
const active = preset === selectedIntervalHours;
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RefObject } from 'preact';
|
||||
import type { JSX, RefObject } from 'preact';
|
||||
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import {
|
||||
@@ -71,6 +71,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: props.id,
|
||||
});
|
||||
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -89,7 +90,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
||||
className="btn btn-secondary small website-drag-btn"
|
||||
title={t('txt_drag_to_reorder')}
|
||||
aria-label={t('txt_drag_to_reorder')}
|
||||
{...attributes}
|
||||
{...dragButtonAttributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} className="btn-icon" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64ToBytes, decryptBw } from './crypto';
|
||||
import { base64ToBytes, decryptBw, toBufferSource } from './crypto';
|
||||
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
|
||||
import type { Profile, SessionState } from './types';
|
||||
|
||||
@@ -9,7 +9,7 @@ const AES_GCM_ALGORITHM = 'AES-GCM';
|
||||
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
pkcs8,
|
||||
toBufferSource(pkcs8),
|
||||
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
||||
false,
|
||||
['decrypt']
|
||||
@@ -17,7 +17,7 @@ async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
||||
}
|
||||
|
||||
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('raw', keyBytes, { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
||||
return crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
||||
}
|
||||
|
||||
export async function decryptPortableBackupSettings(
|
||||
@@ -50,15 +50,15 @@ export async function decryptPortableBackupSettings(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: PORTABLE_ALGORITHM },
|
||||
privateKey,
|
||||
base64ToBytes(wrap.wrappedKey)
|
||||
toBufferSource(base64ToBytes(wrap.wrappedKey))
|
||||
)
|
||||
);
|
||||
const aesKey = await importPortableAesKey(portableDek);
|
||||
const plaintext = new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: AES_GCM_ALGORITHM, iv: base64ToBytes(portable.iv) },
|
||||
{ name: AES_GCM_ALGORITHM, iv: toBufferSource(base64ToBytes(portable.iv)) },
|
||||
aesKey,
|
||||
base64ToBytes(portable.ciphertext)
|
||||
toBufferSource(base64ToBytes(portable.ciphertext))
|
||||
)
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type AuthedFetch,
|
||||
} from './shared';
|
||||
import { readResponseBytesWithProgress } from '../download';
|
||||
import { toBufferSource } from '../crypto';
|
||||
import { unzipSync, zipSync } from 'fflate';
|
||||
|
||||
export type {
|
||||
@@ -367,7 +368,7 @@ export function extractBackupFileChecksumPrefix(fileName: string): string | null
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
const digest = await crypto.subtle.digest('SHA-256', toBufferSource(bytes));
|
||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
|
||||
@@ -152,10 +152,12 @@ export async function createSend(
|
||||
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
|
||||
const uploadUrl = uploadInfo?.url;
|
||||
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
||||
const payload = new ArrayBuffer(encryptedFileBytes.byteLength);
|
||||
new Uint8Array(payload).set(encryptedFileBytes);
|
||||
const uploadResp = await uploadDirectEncryptedPayload({
|
||||
accessToken: session.accessToken,
|
||||
uploadUrl,
|
||||
payload: encryptedFileBytes,
|
||||
payload,
|
||||
fileUploadType: uploadInfo?.fileUploadType,
|
||||
unsupportedMessage: 'Unsupported send upload type',
|
||||
onProgress,
|
||||
|
||||
@@ -63,14 +63,14 @@ interface UploadWithProgressOptions {
|
||||
accessToken?: string;
|
||||
method?: string;
|
||||
headers?: HeadersInit;
|
||||
body?: Document | XMLHttpRequestBodyInit | null;
|
||||
body?: XMLHttpRequestBodyInit | null;
|
||||
onProgress?: (percent: number | null) => void;
|
||||
}
|
||||
|
||||
interface DirectEncryptedUploadOptions {
|
||||
accessToken: string;
|
||||
uploadUrl: string;
|
||||
payload: ArrayBuffer | Uint8Array;
|
||||
payload: XMLHttpRequestBodyInit;
|
||||
fileUploadType: number | null | undefined;
|
||||
unsupportedMessage: string;
|
||||
onProgress?: (percent: number | null) => void;
|
||||
|
||||
@@ -18,7 +18,7 @@ export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
return out;
|
||||
}
|
||||
|
||||
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
||||
export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
||||
return new Uint8Array(bytes).buffer;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["../shared/*"]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||
Reference in New Issue
Block a user