Files
bambuddy/frontend/src/components/GitHubBackupSettings.tsx
T
maziggy 48a7024b96 security(github-backup): refuse to save against a non-private repository
While auditing real-world Bambuddy backup repos on GitHub I found
  several left public. That's a serious leak: the settings backup only
  filters bambu_cloud_token and auth_secret_key, so mqtt_username,
  mqtt_password, ha_token, prometheus_token, bambu_cloud_email,
  external_url, and the printer access codes (via K-profiles) were going
  to whatever visibility the user picked.

  Hard guard at every save and re-checked on every push:

  - POST /github-backup/config and PATCH /github-backup/config (when URL,
    token, or provider changes) run a connection test internally and
    return 400 unless is_private comes back True.
  - run_backup() re-checks before each scheduled or manual push, so a
    repository that flipped from private to public gets a clear
    "Backup aborted: the target repository is no longer private" failure.

  Each provider's test_connection now returns is_private (GitHub /
  Gitea / Forgejo read data.private, GitLab reads visibility=="private";
  "internal" is treated as non-private). None means "couldn't determine"
  and is also rejected -- safer to fail closed.

  Frontend renders visibility inline on Test Connection: green check when
  private, red warning panel listing every credential at risk when public,
  yellow when unknown.

---

  ui(github-backup): show save-failure messages inline on the card

  The new "repository is not private" rejection message is ~250 characters
  listing every credential the backup carries (MQTT password, HA token,
  Prometheus token, Bambu Cloud email, printer access codes), which clips
  badly in a toast.

  Both the initial-setup save and the debounced autosave now stash the
  backend's error message into a saveError state and render it as a red
  inline banner above the test-result block, with whitespace-pre-wrap so
  the full message stays readable. The banner clears on success, on the
  next save attempt, and when the user starts editing URL / token / provider
  -- the three fields whose changes invalidate the privacy check -- so it
  doesn't linger after the user has already addressed the cause.

  Short success toasts (Settings saved, Token updated, Backup enabled) are
  unchanged.
2026-05-17 15:30:05 +02:00

1440 lines
61 KiB
TypeScript

import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Github,
Play,
Clock,
CheckCircle,
XCircle,
Loader2,
ExternalLink,
RefreshCw,
Download,
Upload,
Database,
History,
SkipForward,
AlertTriangle,
Trash2,
RotateCcw,
FolderArchive,
} from 'lucide-react';
import { api } from '../api/client';
import type {
GitHubBackupConfig,
GitHubBackupConfigCreate,
GitHubBackupLog,
GitHubBackupStatus,
GitHubBackupTriggerResponse,
GitProviderType,
LocalBackupFile,
LocalBackupStatus,
ScheduleType,
CloudAuthStatus,
Printer,
} from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { Toggle } from './Toggle';
import { ConfirmModal } from './ConfirmModal';
import { useToast } from '../contexts/ToastContext';
import { formatRelativeTime, parseUTCDate } from '../utils/date';
function formatDateTime(dateStr: string | null): string {
if (!dateStr) return '-';
const date = parseUTCDate(dateStr);
if (!date) return '-';
return date.toLocaleString();
}
interface StatusBadgeProps {
status: string | null;
}
function StatusBadge({ status }: StatusBadgeProps) {
if (!status) return null;
const styles: Record<string, string> = {
success: 'bg-green-500/20 text-green-400',
failed: 'bg-red-500/20 text-red-400',
skipped: 'bg-yellow-500/20 text-yellow-400',
running: 'bg-blue-500/20 text-blue-400',
};
const icons: Record<string, React.ReactNode> = {
success: <CheckCircle className="w-3 h-3" />,
failed: <XCircle className="w-3 h-3" />,
skipped: <SkipForward className="w-3 h-3" />,
running: <Loader2 className="w-3 h-3 animate-spin" />,
};
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${styles[status] || 'bg-gray-500/20 text-gray-400'}`}>
{icons[status]}
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
const PROVIDER_REPO_URL_I18N_KEY: Record<GitProviderType, string> = {
github: 'backup.repoUrlPlaceholderGitHub',
gitea: 'backup.repoUrlPlaceholderGitea',
forgejo: 'backup.repoUrlPlaceholderForgejo',
gitlab: 'backup.repoUrlPlaceholderGitLab',
};
const PROVIDER_TOKEN_PLACEHOLDER: Record<GitProviderType, string> = {
github: 'ghp_xxxxxxxxxxxx',
gitea: 'your_access_token',
forgejo: 'your_access_token',
gitlab: 'glpat-xxxxxxxxxxxx',
};
interface GitHubBackupAutosaveState {
repository_url: string;
branch: string;
provider: GitProviderType;
allow_insecure_http: boolean;
schedule_enabled: boolean;
schedule_type: ScheduleType;
backup_kprofiles: boolean;
backup_cloud_profiles: boolean;
backup_settings: boolean;
backup_spools: boolean;
backup_archives: boolean;
enabled: boolean;
}
function serializeAutosaveState(state: GitHubBackupAutosaveState): string {
return JSON.stringify(state);
}
function getChangedAutosaveFields(
current: GitHubBackupAutosaveState,
previous: GitHubBackupAutosaveState | null
): Partial<GitHubBackupAutosaveState> {
if (!previous) {
return current;
}
const changes: Partial<GitHubBackupAutosaveState> = {};
for (const key of Object.keys(current) as Array<keyof GitHubBackupAutosaveState>) {
if (current[key] !== previous[key]) {
changes[key] = current[key] as never;
}
}
return changes;
}
export function GitHubBackupSettings() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const { t } = useTranslation();
// Local state for form
const [repoUrl, setRepoUrl] = useState('');
const [accessToken, setAccessToken] = useState('');
const [branch, setBranch] = useState('main');
const [provider, setProvider] = useState<GitProviderType>('github');
const [scheduleEnabled, setScheduleEnabled] = useState(false);
const [scheduleType, setScheduleType] = useState<ScheduleType>('daily');
const [backupKProfiles, setBackupKProfiles] = useState(true);
const [backupCloudProfiles, setBackupCloudProfiles] = useState(true);
const [backupSettings, setBackupSettings] = useState(false);
const [backupSpools, setBackupSpools] = useState(false);
const [backupArchives, setBackupArchives] = useState(false);
const [allowInsecureHttp, setAllowInsecureHttp] = useState(false);
const [enabled, setEnabled] = useState(true);
// Local backup state
const [isExporting, setIsExporting] = useState(false);
const [isRestoring, setIsRestoring] = useState(false);
const [operationStatus, setOperationStatus] = useState<string>('');
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
const [restoreFile, setRestoreFile] = useState<File | null>(null);
const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Scheduled local backup state
const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
const [restoreConfirmFile, setRestoreConfirmFile] = useState<string | null>(null);
const [localBackupPath, setLocalBackupPath] = useState('');
const { data: localBackupStatus, refetch: refetchLocalStatus } = useQuery<LocalBackupStatus>({
queryKey: ['local-backup-status'],
queryFn: api.getLocalBackupStatus,
refetchInterval: (query) => query.state.data?.is_running ? 1000 : 10000,
});
const { data: localBackups, refetch: refetchLocalBackups } = useQuery<LocalBackupFile[]>({
queryKey: ['local-backup-files'],
queryFn: api.getLocalBackups,
refetchInterval: 30000,
});
// Sync local path state from server
useEffect(() => {
if (localBackupStatus?.path !== undefined) {
setLocalBackupPath(localBackupStatus.path);
}
}, [localBackupStatus?.path]);
const triggerLocalBackupMutation = useMutation({
mutationFn: api.triggerLocalBackup,
onSuccess: (data) => {
if (data.success) {
showToast(t('backup.scheduledBackupComplete'));
} else {
showToast(data.message, 'error');
}
refetchLocalStatus();
refetchLocalBackups();
},
onError: () => showToast(t('backup.scheduledBackupFailed'), 'error'),
});
const deleteLocalBackupMutation = useMutation({
mutationFn: (filename: string) => api.deleteLocalBackup(filename),
onSuccess: () => {
refetchLocalBackups();
setDeleteConfirmFile(null);
},
});
const restoreLocalBackupMutation = useMutation({
mutationFn: async (filename: string) => {
setRestoreConfirmFile(null);
setIsRestoring(true);
setRestoreResult(null);
setOperationStatus(t('backup.restoring'));
return api.restoreLocalBackup(filename);
},
onSuccess: (data) => {
setIsRestoring(false);
setOperationStatus('');
if (data.success) {
setRestoreResult({ success: true, message: data.message });
showToast(t('backup.backupRestoredRestart'), 'success');
} else {
setRestoreResult({ success: false, message: data.message });
showToast(data.message, 'error');
}
},
onError: (e) => {
setIsRestoring(false);
setOperationStatus('');
const msg = e instanceof Error ? e.message : t('backup.failedToRestore');
setRestoreResult({ success: false, message: msg });
showToast(msg, 'error');
},
});
// Block navigation while backup/restore is in progress
useEffect(() => {
const isOperationInProgress = isExporting || isRestoring;
if (isOperationInProgress) {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = 'A backup operation is in progress. Are you sure you want to leave?';
return e.returnValue;
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}
}, [isExporting, isRestoring]);
// Test connection state
const [testLoading, setTestLoading] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
isPrivate: boolean | null;
} | null>(null);
// Inline save-error banner — backend rejection messages (e.g. the
// "repository is not private" guard) are far too long for a toast.
// Cleared on success, on the next save attempt, and when the user starts
// editing the repo URL / token / provider so the banner doesn't persist
// after the user has already addressed the cause.
const [saveError, setSaveError] = useState<string | null>(null);
// Auto-save debounce
const settingsAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const tokenAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initializationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSavedAutosaveStateRef = useRef<GitHubBackupAutosaveState | null>(null);
const lastTokenScheduledForSaveRef = useRef('');
const [isInitialized, setIsInitialized] = useState(false);
const autoSaveState = useMemo<GitHubBackupAutosaveState>(() => ({
repository_url: repoUrl,
branch,
provider,
allow_insecure_http: allowInsecureHttp,
schedule_enabled: scheduleEnabled,
schedule_type: scheduleType,
backup_kprofiles: backupKProfiles,
backup_cloud_profiles: backupCloudProfiles,
backup_settings: backupSettings,
backup_spools: backupSpools,
backup_archives: backupArchives,
enabled,
}), [repoUrl, branch, provider, allowInsecureHttp, scheduleEnabled, scheduleType, backupKProfiles, backupCloudProfiles, backupSettings, backupSpools, backupArchives, enabled]);
const autoSaveStateFingerprint = useMemo(
() => serializeAutosaveState(autoSaveState),
[autoSaveState]
);
// Queries
const { data: config, isLoading: configLoading } = useQuery<GitHubBackupConfig | null>({
queryKey: ['github-backup-config'],
queryFn: api.getGitHubBackupConfig,
});
const { data: status } = useQuery<GitHubBackupStatus>({
queryKey: ['github-backup-status'],
queryFn: api.getGitHubBackupStatus,
refetchInterval: (query) => query.state.data?.is_running ? 500 : 10000, // Poll fast during backup
});
const { data: logs } = useQuery<GitHubBackupLog[]>({
queryKey: ['github-backup-logs'],
queryFn: () => api.getGitHubBackupLogs(20),
});
const { data: cloudStatus } = useQuery<CloudAuthStatus>({
queryKey: ['cloud-status'],
queryFn: api.getCloudStatus,
});
// Fetch printers and their statuses for K-profile availability
const { data: printers } = useQuery<Printer[]>({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
// Fetch printer statuses from API (not just cache) to get accurate connection status
const printerStatusQueries = useQueries({
queries: (printers ?? []).map(printer => ({
queryKey: ['printerStatus', printer.id],
queryFn: () => api.getPrinterStatus(printer.id),
staleTime: 10000, // Consider stale after 10s
refetchInterval: 30000, // Refresh every 30s
})),
});
const printerStatuses = (printers ?? []).map((printer, index) => ({
printer,
connected: printerStatusQueries[index]?.data?.connected ?? false,
}));
const totalPrinters = printerStatuses.length;
const connectedPrinters = printerStatuses.filter(p => p.connected).length;
const noPrintersConnected = totalPrinters > 0 && connectedPrinters === 0;
const somePrintersDisconnected = connectedPrinters > 0 && connectedPrinters < totalPrinters;
// Initialize form from config
useEffect(() => {
if (initializationTimerRef.current) {
clearTimeout(initializationTimerRef.current);
}
if (config) {
setIsInitialized(false);
lastSavedAutosaveStateRef.current = {
repository_url: config.repository_url,
branch: config.branch,
provider: config.provider ?? 'github',
allow_insecure_http: config.allow_insecure_http ?? false,
schedule_enabled: config.schedule_enabled,
schedule_type: config.schedule_type,
backup_kprofiles: config.backup_kprofiles,
backup_cloud_profiles: config.backup_cloud_profiles,
backup_settings: config.backup_settings,
backup_spools: config.backup_spools,
backup_archives: config.backup_archives,
enabled: config.enabled,
};
setRepoUrl(config.repository_url);
setBranch(config.branch);
setProvider(config.provider ?? 'github');
setScheduleEnabled(config.schedule_enabled);
setScheduleType(config.schedule_type);
setBackupKProfiles(config.backup_kprofiles);
setBackupCloudProfiles(config.backup_cloud_profiles);
setBackupSettings(config.backup_settings);
setBackupSpools(config.backup_spools);
setBackupArchives(config.backup_archives);
setAllowInsecureHttp(config.allow_insecure_http ?? false);
setEnabled(config.enabled);
setAccessToken(''); // Don't show stored token
// Mark as initialized after a tick to avoid auto-save on initial load
initializationTimerRef.current = setTimeout(() => { setIsInitialized(true); }, 100);
} else {
setIsInitialized(false);
lastSavedAutosaveStateRef.current = null;
setAccessToken('');
}
return () => {
if (initializationTimerRef.current) {
clearTimeout(initializationTimerRef.current);
}
};
}, [config]);
// Auto-save function for existing configs
const autoSave = useCallback(async (includeToken: boolean = false) => {
if (!config) return; // Only auto-save if config already exists
try {
if (includeToken && accessToken) {
// Full save with new token
await api.saveGitHubBackupConfig({
...autoSaveState,
access_token: accessToken,
});
setAccessToken(''); // Clear after save
setSaveError(null);
showToast(t('backup.tokenUpdated'));
lastTokenScheduledForSaveRef.current = '';
} else {
// Update without token
await api.updateGitHubBackupConfig(getChangedAutosaveFields(
autoSaveState,
lastSavedAutosaveStateRef.current
));
setSaveError(null);
showToast(t('backup.settingsSaved'));
}
lastSavedAutosaveStateRef.current = autoSaveState;
queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
} catch (error) {
setSaveError((error as Error).message);
}
}, [config, accessToken, autoSaveState, queryClient, showToast, t]);
const autoSaveRef = useRef(autoSave);
useEffect(() => {
autoSaveRef.current = autoSave;
}, [autoSave]);
// Auto-save effect for existing configs (debounced)
useEffect(() => {
if (!isInitialized || !config) return;
if (
lastSavedAutosaveStateRef.current
&& autoSaveStateFingerprint === serializeAutosaveState(lastSavedAutosaveStateRef.current)
) return;
if (settingsAutoSaveTimerRef.current) {
clearTimeout(settingsAutoSaveTimerRef.current);
}
settingsAutoSaveTimerRef.current = setTimeout(() => {
autoSave(false);
}, 500);
return () => {
if (settingsAutoSaveTimerRef.current) {
clearTimeout(settingsAutoSaveTimerRef.current);
}
};
}, [isInitialized, config, autoSaveStateFingerprint, autoSave]);
// Auto-save token when it changes (with longer debounce)
useEffect(() => {
if (!isInitialized || !config || !accessToken) return;
if (accessToken === lastTokenScheduledForSaveRef.current) return;
lastTokenScheduledForSaveRef.current = accessToken;
if (tokenAutoSaveTimerRef.current) {
clearTimeout(tokenAutoSaveTimerRef.current);
}
tokenAutoSaveTimerRef.current = setTimeout(() => {
autoSaveRef.current(true);
}, 1000);
return () => {
if (tokenAutoSaveTimerRef.current) {
clearTimeout(tokenAutoSaveTimerRef.current);
}
};
}, [isInitialized, accessToken, config]);
// Mutations
const saveConfigMutation = useMutation({
mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data),
onMutate: () => {
setSaveError(null);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
showToast(t('backup.githubBackupEnabled'));
setAccessToken('');
setIsInitialized(true);
},
onError: (error: Error) => {
setSaveError(error.message);
},
});
const triggerBackupMutation = useMutation<GitHubBackupTriggerResponse, Error>({
mutationFn: api.triggerGitHubBackup,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
if (result.success) {
if (result.files_changed > 0) {
showToast(t('backup.backupCompleteFiles', { count: result.files_changed }));
} else {
showToast(t('backup.backupSkippedNoChanges'));
}
} else {
showToast(t('backup.backupFailed2', { message: result.message }), 'error');
}
},
onError: (error: Error) => {
showToast(t('backup.backupFailed2', { message: error.message }), 'error');
},
});
const clearLogsMutation = useMutation<{ deleted: number; message: string }, Error>({
mutationFn: () => api.clearGitHubBackupLogs(0),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
showToast(t('backup.clearedLogs', { count: result.deleted }));
},
onError: (error: Error) => {
showToast(t('backup.failedToClearLogs', { message: error.message }), 'error');
},
});
const handleTestConnection = async () => {
setTestLoading(true);
setTestResult(null);
try {
let result;
// If user entered a new token, test with those credentials
if (accessToken) {
if (!repoUrl) {
showToast(t('backup.enterRepoUrl'), 'error');
setTestLoading(false);
return;
}
result = await api.testGitHubConnection(repoUrl, accessToken, provider);
} else if (config?.has_token) {
// Use stored credentials
result = await api.testGitHubStoredConnection();
} else {
showToast(t('backup.enterRepoAndToken'), 'error');
setTestLoading(false);
return;
}
setTestResult({
success: result.success,
message: result.message,
isPrivate: result.is_private,
});
} catch (error) {
setTestResult({ success: false, message: (error as Error).message, isPrivate: null });
} finally {
setTestLoading(false);
}
};
// Initial setup save (only for new configs)
const handleInitialSetup = () => {
if (!repoUrl) {
showToast(t('backup.repoRequired'), 'error');
return;
}
if (!accessToken) {
showToast(t('backup.tokenRequired'), 'error');
return;
}
saveConfigMutation.mutate({
repository_url: repoUrl,
access_token: accessToken,
branch,
provider,
allow_insecure_http: allowInsecureHttp,
schedule_enabled: scheduleEnabled,
schedule_type: scheduleType,
backup_kprofiles: backupKProfiles,
backup_cloud_profiles: backupCloudProfiles,
backup_settings: backupSettings,
backup_spools: backupSpools,
backup_archives: backupArchives,
enabled,
});
};
if (configLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
</div>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - Git Backup */}
<div className="space-y-6">
<Card id="card-backup-github">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Github className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">{t('backup.githubBackup')}</h2>
</div>
{config && (
<div className="flex items-center gap-2">
<span className="text-sm text-bambu-gray">{t('backup.enabled')}</span>
<Toggle
checked={enabled}
onChange={setEnabled}
/>
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
{t('backup.githubDescription')}
</p>
{/* Provider Selection */}
<div>
<label htmlFor="git-provider-select" className="block text-sm text-bambu-gray mb-1">{t('backup.provider')}</label>
<select
id="git-provider-select"
value={provider}
onChange={(e) => { setProvider(e.target.value as GitProviderType); setTestResult(null); setSaveError(null); }}
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="github">{t('backup.providerGitHub')}</option>
<option value="gitlab">{t('backup.providerGitLab')}</option>
<option value="gitea">{t('backup.providerGitea')}</option>
<option value="forgejo">{t('backup.providerForgejo')}</option>
</select>
</div>
{/* Repository URL */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('backup.repositoryUrl')}
</label>
<input
type="text"
value={repoUrl}
onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); setSaveError(null); }}
placeholder={t(PROVIDER_REPO_URL_I18N_KEY[provider])}
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
<label className="flex items-start gap-2 mt-2 cursor-pointer">
<input
type="checkbox"
checked={allowInsecureHttp}
onChange={(e) => { setAllowInsecureHttp(e.target.checked); setTestResult(null); }}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<div>
<span className="text-sm text-white">{t('backup.allowInsecureHttp')}</span>
<p className="text-xs text-bambu-gray">{t('backup.allowInsecureHttpHint')}</p>
</div>
</label>
</div>
{/* Access Token */}
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('backup.personalAccessToken')} {config?.has_token && <span className="text-green-400">{t('backup.tokenSaved')}</span>}
</label>
<input
type="password"
value={accessToken}
onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); setSaveError(null); }}
placeholder={config?.has_token ? t('backup.enterNewToken') : PROVIDER_TOKEN_PLACEHOLDER[provider]}
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
<p className="text-xs text-bambu-gray mt-1">
{t('backup.tokenHint')}
</p>
</div>
{/* Branch - inline with schedule */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.branch')}</label>
<input
type="text"
value={branch}
onChange={(e) => setBranch(e.target.value)}
placeholder="main"
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.autoBackup')}</label>
<select
value={scheduleEnabled ? scheduleType : 'disabled'}
onChange={(e) => {
if (e.target.value === 'disabled') {
setScheduleEnabled(false);
} else {
setScheduleEnabled(true);
setScheduleType(e.target.value as ScheduleType);
}
}}
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
>
<option value="disabled">{t('backup.manualOnly')}</option>
<option value="hourly">{t('backup.hourly')}</option>
<option value="daily">{t('backup.daily')}</option>
<option value="weekly">{t('backup.weekly')}</option>
</select>
</div>
</div>
{/* What to backup */}
<div>
<label className="block text-sm text-bambu-gray mb-2">{t('backup.includeInBackup')}</label>
<div className="space-y-2">
<label className={`flex items-start gap-2 ${noPrintersConnected ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
<input
type="checkbox"
checked={backupKProfiles}
onChange={(e) => setBackupKProfiles(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
disabled={noPrintersConnected}
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className={`text-sm ${noPrintersConnected ? 'text-bambu-gray' : 'text-white'}`}>{t('backup.kProfiles')}</span>
{noPrintersConnected && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
<AlertTriangle className="w-3 h-3" />
{t('backup.noPrintersConnected')}
</span>
)}
{somePrintersDisconnected && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
<AlertTriangle className="w-3 h-3" />
{t('backup.printersConnected', { connected: connectedPrinters, total: totalPrinters })}
</span>
)}
</div>
<p className="text-xs text-bambu-gray">{t('backup.kProfilesDescription')}</p>
</div>
</label>
<label className={`flex items-start gap-2 ${!cloudStatus?.is_authenticated ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
<input
type="checkbox"
checked={backupCloudProfiles}
onChange={(e) => setBackupCloudProfiles(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
disabled={!cloudStatus?.is_authenticated}
/>
<div>
<div className="flex items-center gap-2">
<span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
{!cloudStatus?.is_authenticated && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
<AlertTriangle className="w-3 h-3" />
{t('backup.cloudLoginRequiredShort')}
</span>
)}
</div>
<p className="text-xs text-bambu-gray">{t('backup.cloudProfilesDescription')}</p>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={backupSettings}
onChange={(e) => setBackupSettings(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<div>
<span className="text-white text-sm">{t('backup.appSettings')}</span>
<p className="text-xs text-bambu-gray">{t('backup.appSettingsDescription')}</p>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={backupSpools}
onChange={(e) => setBackupSpools(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<div>
<span className="text-white text-sm">{t('backup.spoolInventory')}</span>
<p className="text-xs text-bambu-gray">{t('backup.spoolInventoryDescription')}</p>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={backupArchives}
onChange={(e) => setBackupArchives(e.target.checked)}
className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<div>
<span className="text-white text-sm">{t('backup.printArchives')}</span>
<p className="text-xs text-bambu-gray">{t('backup.printArchivesDescription')}</p>
</div>
</label>
</div>
</div>
{/* Test + Status + Actions */}
<div className="border-t border-bambu-dark-tertiary pt-4 space-y-3">
{/* Status line */}
{status?.configured && (
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-bambu-gray">
{status.last_backup_at ? (
<>
<span>{t('backup.lastBackupAt')} {formatRelativeTime(status.last_backup_at, 'system', t)}</span>
<StatusBadge status={status.last_backup_status} />
</>
) : (
<span>{t('backup.noBackupsYet')}</span>
)}
</div>
{status.next_scheduled_run && (
<span className="text-bambu-gray">
<Clock className="w-3 h-3 inline mr-1" />
{t('backup.next')} {formatRelativeTime(status.next_scheduled_run, 'system', t)}
</span>
)}
</div>
)}
{/* Save error — inline banner. Keeps long backend rejection
messages (e.g. the "repository is not private" guard)
readable instead of clipped to a toast. */}
{saveError && (
<div className="text-sm text-red-400 bg-red-500/10 border border-red-500/30 rounded p-3 flex items-start gap-2">
<XCircle className="w-4 h-4 mt-0.5 shrink-0" />
<div className="flex-1 leading-relaxed whitespace-pre-wrap break-words">{saveError}</div>
<button
type="button"
onClick={() => setSaveError(null)}
className="text-bambu-gray hover:text-white shrink-0"
aria-label={t('common.dismiss', 'Dismiss')}
>
<XCircle className="w-4 h-4" />
</button>
</div>
)}
{/* Test result */}
{testResult && (
<div className="space-y-1.5">
<div className={`text-sm flex items-center gap-1 ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
{testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
{testResult.message}
</div>
{testResult.success && testResult.isPrivate === true && (
<div className="text-xs flex items-center gap-1 text-green-400">
<CheckCircle className="w-3.5 h-3.5" />
{t('backup.repoIsPrivate', 'Repository is private — safe to back up to.')}
</div>
)}
{testResult.success && testResult.isPrivate === false && (
<div className="text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded p-2 flex items-start gap-1.5">
<XCircle className="w-4 h-4 mt-0.5 shrink-0" />
<span>
{t(
'backup.repoIsPublicWarning',
'Repository is PUBLIC. Bambuddy backups include MQTT credentials, Home Assistant tokens, Prometheus tokens, your Bambu Cloud email, and printer access codes via K-profiles. Saving is blocked until you make the repository private in your provider\'s settings.',
)}
</span>
</div>
)}
{testResult.success && testResult.isPrivate === null && (
<div className="text-xs text-yellow-400 bg-yellow-500/10 border border-yellow-500/30 rounded p-2 flex items-start gap-1.5">
<XCircle className="w-4 h-4 mt-0.5 shrink-0" />
<span>
{t(
'backup.repoVisibilityUnknown',
'Could not determine repository visibility. Bambuddy refuses to back up to anything not confirmed private; saving will be blocked.',
)}
</span>
</div>
)}
</div>
)}
{/* Action buttons */}
<div className="flex flex-wrap items-center gap-2">
{status?.configured ? (
<>
{(triggerBackupMutation.isPending || status.is_running) ? (
<div className="flex items-center gap-2 text-bambu-green">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm">{status.progress || t('backup.startingBackup')}</span>
</div>
) : (
<>
<Button
variant="primary"
size="sm"
onClick={() => triggerBackupMutation.mutate()}
disabled={!config?.enabled}
>
<Play className="w-4 h-4" />
{t('backup.backupNow')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleTestConnection}
disabled={testLoading}
>
{testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{t('backup.test')}
</Button>
</>
)}
</>
) : (
<>
<Button
variant="primary"
size="sm"
onClick={handleInitialSetup}
disabled={saveConfigMutation.isPending || !repoUrl || !accessToken}
>
{saveConfigMutation.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
{t('backup.enableBackup')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleTestConnection}
disabled={testLoading || !repoUrl || !accessToken}
>
{testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
{t('backup.testConnection')}
</Button>
</>
)}
</div>
</div>
</CardContent>
</Card>
{/* Backup History - only show if configured and has logs */}
{logs && logs.length > 0 && (
<Card id="card-backup-history">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<History className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">{t('backup.history')}</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => clearLogsMutation.mutate()}
disabled={clearLogsMutation.isPending}
>
<Trash2 className="w-4 h-4" />
{t('backup.clear')}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
<th className="text-left py-2 px-2">{t('backup.date')}</th>
<th className="text-left py-2 px-2">{t('backup.status')}</th>
<th className="text-left py-2 px-2">{t('backup.commit')}</th>
</tr>
</thead>
<tbody>
{logs.slice(0, 10).map((log) => (
<tr key={log.id} className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary">
<td className="py-2 px-2 text-white">{formatDateTime(log.started_at)}</td>
<td className="py-2 px-2"><StatusBadge status={log.status} /></td>
<td className="py-2 px-2">
{log.commit_sha ? (
<a
href={`${config?.repository_url}/commit/${log.commit_sha}`}
target="_blank"
rel="noopener noreferrer"
className="text-bambu-green hover:underline inline-flex items-center gap-1"
>
{log.commit_sha.substring(0, 7)}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<span className="text-bambu-gray">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
{/* Right Column - Local Backup */}
<div className="space-y-6">
<Card id="card-backup-local">
<CardHeader>
<div className="flex items-center gap-2">
<Database className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">{t('backup.localBackup')}</h2>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
{t('backup.localBackupDescription')}
</p>
{/* Export */}
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
<div>
<p className="text-white">{t('backup.downloadBackupLabel')}</p>
<p className="text-sm text-bambu-gray">
{t('backup.completeBackupZip')}
</p>
</div>
<Button
variant="secondary"
size="sm"
disabled={isExporting || isRestoring}
onClick={async () => {
setIsExporting(true);
setOperationStatus(t('backup.preparingBackup'));
try {
setOperationStatus(t('backup.creatingArchive'));
const { blob, filename } = await api.exportBackup();
setOperationStatus(t('backup.downloadingFile'));
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast(t('backup.backupDownloaded'));
} catch (e) {
showToast(t('backup.failedToCreateBackup', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
} finally {
setIsExporting(false);
setOperationStatus('');
}
}}
>
<Download className="w-4 h-4" />
{t('backup.download')}
</Button>
</div>
{/* Import */}
<div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
<div>
<p className="text-white">{t('backup.restoreBackup')}</p>
<p className="text-sm text-bambu-gray">
{t('backup.restoreDescription')}
</p>
<p className="text-xs text-bambu-gray-light mt-1">
{t('backup.restoreNote')}
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".zip"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setRestoreFile(file);
setShowRestoreConfirm(true);
}
e.target.value = '';
}}
/>
<Button
variant="secondary"
size="sm"
disabled={isRestoring || isExporting}
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4" />
{t('backup.restore')}
</Button>
</div>
{/* Restore result message */}
{restoreResult && (
<div className={`p-3 rounded-lg ${restoreResult.success ? 'bg-green-500/10 border border-green-500/30' : 'bg-red-500/10 border border-red-500/30'}`}>
<div className="flex items-start gap-2 text-sm">
{restoreResult.success ? (
<CheckCircle className="w-4 h-4 text-green-400 mt-0.5 flex-shrink-0" />
) : (
<XCircle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
)}
<div className={restoreResult.success ? 'text-green-200' : 'text-red-200'}>
{restoreResult.message}
{restoreResult.success && (
<div className="mt-2">
<Button
size="sm"
onClick={() => window.location.reload()}
>
<RotateCcw className="w-3 h-3" />
{t('backup.reloadNow')}
</Button>
</div>
)}
</div>
</div>
</div>
)}
{/* Warning */}
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<div className="text-yellow-200">
<span className="font-medium">{t('backup.restoreReplacesAll')}</span>{' '}
<span className="text-yellow-200/70">{t('backup.restoreReplacesAllDetail')}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Scheduled Local Backups */}
<Card id="card-backup-scheduled">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FolderArchive className="w-5 h-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">{t('backup.scheduledBackup')}</h2>
</div>
<Toggle
checked={localBackupStatus?.enabled ?? false}
onChange={async (checked) => {
try {
await api.updateSettings({ local_backup_enabled: checked });
queryClient.invalidateQueries({ queryKey: ['settings'] });
showToast(t('backup.settingsSaved'));
} catch (e) {
showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
}
refetchLocalStatus();
}}
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-bambu-gray">
{t('backup.scheduledBackupDescription')}
</p>
{localBackupStatus?.enabled && (
<>
{/* Schedule + Time + Retention */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.frequency')}</label>
<select
value={localBackupStatus?.schedule ?? 'daily'}
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
onChange={async (e) => {
try {
await api.updateSettings({ local_backup_schedule: e.target.value });
showToast(t('backup.settingsSaved'));
} catch (e) {
showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
}
refetchLocalStatus();
}}
>
<option value="hourly">{t('backup.hourly')}</option>
<option value="daily">{t('backup.daily')}</option>
<option value="weekly">{t('backup.weekly')}</option>
</select>
</div>
{(localBackupStatus?.schedule ?? 'daily') !== 'hourly' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.backupTime')}</label>
<input
type="time"
value={localBackupStatus?.time ?? '03:00'}
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none [color-scheme:dark]"
onChange={async (e) => {
try {
await api.updateSettings({ local_backup_time: e.target.value });
showToast(t('backup.settingsSaved'));
} catch (err) {
showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error');
}
refetchLocalStatus();
}}
/>
<p className="text-xs text-bambu-gray-light mt-1">{t('backup.utc')}</p>
</div>
)}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.retention')}</label>
<input
type="number"
min={1}
max={100}
value={localBackupStatus?.retention ?? 5}
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
onChange={async (e) => {
const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 5));
try {
await api.updateSettings({ local_backup_retention: val });
showToast(t('backup.settingsSaved'));
} catch (e) {
showToast(t('backup.failedToSave', { message: e instanceof Error ? e.message : 'Unknown error' }), 'error');
}
refetchLocalStatus();
}}
/>
<p className="text-xs text-bambu-gray-light mt-1">{t('backup.retentionDescription')}</p>
</div>
</div>
{/* Output Path */}
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('backup.outputPath')}</label>
<input
type="text"
value={localBackupPath}
onChange={(e) => setLocalBackupPath(e.target.value)}
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
onBlur={async () => {
try {
await api.updateSettings({ local_backup_path: localBackupPath });
showToast(t('backup.settingsSaved'));
} catch (err) {
showToast(t('backup.failedToSave', { message: err instanceof Error ? err.message : 'Unknown error' }), 'error');
}
refetchLocalStatus();
refetchLocalBackups();
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
}}
/>
<p className="text-xs text-bambu-gray-light mt-1">
{localBackupPath
? t('backup.outputPathDescription')
: <>{t('backup.defaultPathLabel')} <code className="text-bambu-gray">{localBackupStatus?.default_path || '...'}</code></>
}
</p>
</div>
{/* Status + Run Now */}
<div className="flex items-center justify-between py-3 border-t border-bambu-dark-tertiary">
<div className="text-sm">
{localBackupStatus?.last_backup_at && (
<div className="flex items-center gap-2 text-bambu-gray">
<span>{t('backup.lastBackup')}:</span>
<StatusBadge status={localBackupStatus.last_status} />
<span>{formatRelativeTime(localBackupStatus.last_backup_at)}</span>
</div>
)}
{localBackupStatus?.next_run && (
<div className="text-bambu-gray mt-1">
<span>{t('backup.nextBackup')}: </span>
<span>{formatDateTime(localBackupStatus.next_run)}</span>
</div>
)}
</div>
<Button
variant="secondary"
size="sm"
disabled={localBackupStatus?.is_running || triggerLocalBackupMutation.isPending}
onClick={() => triggerLocalBackupMutation.mutate()}
>
{localBackupStatus?.is_running || triggerLocalBackupMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
{localBackupStatus?.is_running ? t('backup.backupRunning') : t('backup.runNow')}
</Button>
</div>
{/* Backup Files List */}
{localBackups && localBackups.length > 0 && (
<div className="border-t border-bambu-dark-tertiary pt-3">
<h3 className="text-sm font-medium text-white mb-2">{t('backup.backupFiles')}</h3>
<div className="space-y-1">
{localBackups.map((file) => (
<div key={file.filename} className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-bambu-dark-tertiary/50 text-sm">
<div className="flex-1 min-w-0">
<span className="text-white truncate block">{file.filename}</span>
<span className="text-bambu-gray text-xs">
{(file.size / 1024 / 1024).toFixed(1)} MB &middot; {formatDateTime(file.created_at)}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
className="text-bambu-gray hover:text-bambu-green p-1"
title={t('backup.download')}
onClick={async () => {
try {
const { blob, filename: fname } = await api.downloadLocalBackup(file.filename);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fname;
a.click();
URL.revokeObjectURL(url);
} catch {
showToast(t('backup.scheduledBackupFailed'), 'error');
}
}}
>
<Download className="w-3.5 h-3.5" />
</button>
<button
className="text-bambu-gray hover:text-yellow-400 p-1"
title={t('backup.restore')}
onClick={() => setRestoreConfirmFile(file.filename)}
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
<button
className="text-bambu-gray hover:text-red-400 p-1"
onClick={() => setDeleteConfirmFile(file.filename)}
title={t('backup.deleteBackup')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
)}
{localBackups && localBackups.length === 0 && (
<p className="text-sm text-bambu-gray text-center py-3 border-t border-bambu-dark-tertiary">
{t('backup.noScheduledBackups')}
</p>
)}
</>
)}
</CardContent>
</Card>
</div>
{/* Delete Backup Confirmation Modal */}
{deleteConfirmFile && (
<ConfirmModal
title={t('backup.deleteBackup')}
message={t('backup.deleteBackupConfirm')}
confirmText={t('backup.deleteBackup')}
variant="danger"
onConfirm={() => deleteLocalBackupMutation.mutate(deleteConfirmFile)}
onCancel={() => setDeleteConfirmFile(null)}
/>
)}
{/* Restore from Scheduled Backup Confirmation Modal */}
{restoreConfirmFile && (
<ConfirmModal
title={t('backup.restoreConfirmTitle')}
message={t('backup.restoreConfirmMessage', { filename: restoreConfirmFile })}
confirmText={t('backup.restoreConfirmButton')}
variant="danger"
onConfirm={() => restoreLocalBackupMutation.mutate(restoreConfirmFile)}
onCancel={() => setRestoreConfirmFile(null)}
/>
)}
{/* Restore Confirmation Modal */}
{showRestoreConfirm && restoreFile && (
<ConfirmModal
title={t('backup.restoreConfirmTitle')}
message={t('backup.restoreConfirmMessage', { filename: restoreFile.name })}
confirmText={t('backup.restoreConfirmButton')}
variant="danger"
onConfirm={async () => {
setShowRestoreConfirm(false);
setIsRestoring(true);
setRestoreResult(null);
try {
setOperationStatus(t('backup.uploadingFile'));
const result = await api.importBackup(restoreFile);
setRestoreResult(result);
if (result.success) {
showToast(t('backup.backupRestoredRestart'), 'success');
} else {
showToast(result.message, 'error');
}
} catch (e) {
const message = e instanceof Error ? e.message : t('backup.failedToRestore');
setRestoreResult({ success: false, message });
showToast(message, 'error');
} finally {
setIsRestoring(false);
setOperationStatus('');
setRestoreFile(null);
}
}}
onCancel={() => {
setShowRestoreConfirm(false);
setRestoreFile(null);
}}
/>
)}
{/* Blocking overlay during backup/restore operations */}
{(isExporting || isRestoring) && (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-[100]">
<div className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl p-8 max-w-md w-full mx-4 text-center">
<div className="flex justify-center mb-4">
<div className="relative">
<div className="w-16 h-16 border-4 border-bambu-dark-tertiary rounded-full"></div>
<div className="w-16 h-16 border-4 border-bambu-green border-t-transparent rounded-full absolute inset-0 animate-spin"></div>
</div>
</div>
<h3 className="text-xl font-semibold text-white mb-2">
{isExporting ? t('backup.creatingBackup') : t('backup.restoringBackup')}
</h3>
<p className="text-bambu-gray mb-4">
{operationStatus || (isExporting ? t('backup.preparing') : t('backup.processing'))}
</p>
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="flex items-start gap-2 text-sm">
<AlertTriangle className="w-4 h-4 text-yellow-400 mt-0.5 flex-shrink-0" />
<p className="text-yellow-200 text-left">
{t('backup.doNotClosePage')}
</p>
</div>
</div>
</div>
</div>
)}
</div>
);
}