mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: Implement admin backup export and import functionality
- Added new endpoints for exporting and importing instance-level backups. - Introduced user interface components for backup management in the web app. - Enhanced import/export logic to handle attachments and provide detailed summaries. - Updated localization files to include new strings related to backup features. - Improved styling for backup-related UI elements.
This commit is contained in:
+86
-24
@@ -27,6 +27,8 @@ import {
|
||||
createAuthedFetch,
|
||||
createInvite,
|
||||
downloadCipherAttachmentDecrypted,
|
||||
exportAdminBackup,
|
||||
importAdminBackup,
|
||||
importCiphers,
|
||||
createSend,
|
||||
deleteAllInvites,
|
||||
@@ -109,7 +111,12 @@ function asText(value: unknown): string {
|
||||
|
||||
function summarizeImportResult(
|
||||
ciphers: Array<Record<string, unknown>>,
|
||||
folderCount: number
|
||||
folderCount: number,
|
||||
attachmentSummary?: {
|
||||
total: number;
|
||||
imported: number;
|
||||
failed: Array<{ fileName: string; reason: string }>;
|
||||
}
|
||||
): ImportResultSummary {
|
||||
const typeLabel = (type: number): string => {
|
||||
if (type === 1) return t('txt_login');
|
||||
@@ -136,6 +143,9 @@ function summarizeImportResult(
|
||||
totalItems: ciphers.length,
|
||||
folderCount: Math.max(0, folderCount),
|
||||
typeCounts,
|
||||
attachmentCount: Math.max(0, attachmentSummary?.total || 0),
|
||||
importedAttachmentCount: Math.max(0, attachmentSummary?.imported || 0),
|
||||
failedAttachments: attachmentSummary?.failed || [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1122,13 +1132,16 @@ export default function App() {
|
||||
async function uploadImportedAttachments(
|
||||
attachments: ImportAttachmentFile[],
|
||||
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
||||
): Promise<void> {
|
||||
if (!attachments.length) return;
|
||||
): Promise<{ total: number; imported: number; failed: Array<{ fileName: string; reason: string }> }> {
|
||||
if (!attachments.length) {
|
||||
return { total: 0, imported: 0, failed: [] };
|
||||
}
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||
|
||||
const initialCiphers = (await ciphersQuery.refetch()).data || [];
|
||||
const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher]));
|
||||
const unresolved: ImportAttachmentFile[] = [];
|
||||
const failed: Array<{ fileName: string; reason: string }> = [];
|
||||
let imported = 0;
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const sourceId = String(attachment.sourceCipherId || '').trim();
|
||||
@@ -1137,7 +1150,10 @@ export default function App() {
|
||||
const byIndex = Number.isFinite(sourceIndex) ? idMaps.byIndex.get(sourceIndex) : null;
|
||||
const targetCipherId = byId || byIndex || null;
|
||||
if (!targetCipherId) {
|
||||
unresolved.push(attachment);
|
||||
failed.push({
|
||||
fileName: String(attachment.fileName || '').trim() || 'attachment.bin',
|
||||
reason: t('txt_import_attachment_target_not_found'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1145,14 +1161,23 @@ export default function App() {
|
||||
const fileBytes = Uint8Array.from(attachment.bytes);
|
||||
const file = new File([fileBytes], name, { type: 'application/octet-stream' });
|
||||
const cipher = cipherById.get(targetCipherId) || null;
|
||||
await uploadCipherAttachment(authedFetch, session, targetCipherId, file, cipher);
|
||||
}
|
||||
|
||||
if (unresolved.length) {
|
||||
throw new Error(t('txt_failed_to_map_attachments', { count: unresolved.length }));
|
||||
try {
|
||||
await uploadCipherAttachment(authedFetch, session, targetCipherId, file, cipher);
|
||||
imported += 1;
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
fileName: name,
|
||||
reason: error instanceof Error ? error.message : t('txt_upload_attachment_failed'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ciphersQuery.refetch();
|
||||
return {
|
||||
total: attachments.length,
|
||||
imported,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
function toImportedCipherMapsFromResponse(
|
||||
@@ -1252,10 +1277,10 @@ export default function App() {
|
||||
const idMaps = buildImportedCipherMaps(payload.ciphers, createdCipherIdsByIndex);
|
||||
await foldersQuery.refetch();
|
||||
await ciphersQuery.refetch();
|
||||
if (attachments.length) {
|
||||
await uploadImportedAttachments(attachments, idMaps);
|
||||
}
|
||||
return summarizeImportResult(payload.ciphers, mode === 'original' ? createdFolderCount : 0);
|
||||
const attachmentSummary = attachments.length
|
||||
? await uploadImportedAttachments(attachments, idMaps)
|
||||
: undefined;
|
||||
return summarizeImportResult(payload.ciphers, mode === 'original' ? createdFolderCount : 0, attachmentSummary);
|
||||
}
|
||||
|
||||
async function handleImportEncryptedRawAction(
|
||||
@@ -1280,11 +1305,14 @@ export default function App() {
|
||||
returnCipherMap: attachments.length > 0,
|
||||
});
|
||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||
if (attachments.length) {
|
||||
const idMaps = toImportedCipherMapsFromResponse(importedCipherMap);
|
||||
await uploadImportedAttachments(attachments, idMaps);
|
||||
}
|
||||
return summarizeImportResult(nextPayload.ciphers, mode === 'original' ? nextPayload.folders.length : 0);
|
||||
const attachmentSummary = attachments.length
|
||||
? await uploadImportedAttachments(attachments, toImportedCipherMapsFromResponse(importedCipherMap))
|
||||
: undefined;
|
||||
return summarizeImportResult(
|
||||
nextPayload.ciphers,
|
||||
mode === 'original' ? nextPayload.folders.length : 0,
|
||||
attachmentSummary
|
||||
);
|
||||
}
|
||||
|
||||
async function handleExportAction(request: ExportRequest) {
|
||||
@@ -1519,6 +1547,30 @@ export default function App() {
|
||||
throw new Error(t('txt_unsupported_export_format'));
|
||||
}
|
||||
|
||||
function downloadBytesAsFile(bytes: Uint8Array, fileName: string, mimeType: string) {
|
||||
const blob = new Blob([bytes], { type: mimeType || 'application/octet-stream' });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = fileName || 'download.bin';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
||||
}
|
||||
|
||||
async function handleBackupExportAction() {
|
||||
const payload = await exportAdminBackup(authedFetch);
|
||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||
}
|
||||
|
||||
async function handleBackupImportAction(file: File, replaceExisting: boolean = false) {
|
||||
await importAdminBackup(authedFetch, file, replaceExisting);
|
||||
window.setTimeout(() => {
|
||||
logoutNow();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
||||
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
||||
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
|
||||
@@ -1540,6 +1592,12 @@ export default function App() {
|
||||
}
|
||||
}, [phase, isImportHashRoute, location, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'app' && profile?.role !== 'admin' && location === '/help') {
|
||||
navigate('/vault');
|
||||
}
|
||||
}, [phase, profile?.role, location, navigate]);
|
||||
|
||||
if (jwtWarning) {
|
||||
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
|
||||
}
|
||||
@@ -1695,10 +1753,12 @@ export default function App() {
|
||||
<Shield size={16} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
<Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}>
|
||||
<Cloud size={16} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
</Link>
|
||||
{profile?.role === 'admin' && (
|
||||
<Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}>
|
||||
<Cloud size={16} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={IMPORT_ROUTE} className={`side-link ${isImportRoute ? 'active' : ''}`}>
|
||||
<ArrowUpDown size={14} />
|
||||
<span>{t('nav_import_export')}</span>
|
||||
@@ -1913,7 +1973,9 @@ export default function App() {
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/help">
|
||||
<HelpPage />
|
||||
{profile?.role === 'admin' ? (
|
||||
<HelpPage onExport={handleBackupExportAction} onImport={handleBackupImportAction} onNotify={pushToast} />
|
||||
) : null}
|
||||
</Route>
|
||||
</Switch>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user