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:
shuaiplus
2026-03-08 13:36:51 +08:00
parent 206b0be566
commit eeb477b84c
16 changed files with 1382 additions and 217 deletions
+86 -24
View File
@@ -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>