Add passphrase-protected export/import for integrations, DNS providers, and settings
Nothing let you back up or migrate the app's own configuration short of copying the raw SQLite file. Adds Settings -> Backup: export decrypts every integration/DNS provider credential (normally encrypted at rest with this server's CREDENTIALS_ENCRYPTION_KEY) and re-encrypts the whole payload with a passphrase you choose (scrypt- derived key, AES-256-GCM), so the file is portable to a different instance with a different encryption key rather than being tied to this one. Import decrypts with that passphrase and merges settings onto the current ones; integrations/DNS providers are only added when no existing row shares their type+name, so re-running an import never duplicates or overwrites a working credential. Scope is configuration only — no DNS records, secrets, IPAM, servers, or audit/diagnostic log data. Verified end-to-end against two isolated scratch databases with different encryption keys (proving actual cross-instance portability, not just round-tripping through the same key): export -> encrypt -> write file -> decrypt on the other DB -> import -> re-decrypt the newly created integration/provider using the target's own key, confirming the plaintext credentials survived correctly; a wrong passphrase failed loudly (GCM auth failure) as expected; and re-running the same import a second time skipped both rows instead of duplicating them. Confirmed the real dev database's mtime was untouched throughout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b5a4c6e2d9
commit
23eb7f0d70
6 files changed
+460
No files matched your search
@@ -8,6 +8,7 @@ import { scheduleTailscaleKeyExpiryCheck } from "../services/tailscaleKeyExpiryS
|
||||
import { scheduleLogRetentionPurge } from "../services/logRetentionScheduler.js";
|
||||
import { purgeOldLogs } from "../services/logRetention.js";
|
||||
import { testGotify, testNtfy, testSmtp, testWebhook } from "../services/notify.js";
|
||||
import { buildExportPayload, encryptExport, decryptExport, applyImportPayload, type EncryptedExportFile } from "../services/configBackup.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
export const settingsRouter = Router();
|
||||
@@ -174,3 +175,60 @@ settingsRouter.post("/purge-logs", requireRole("admin"), asyncHandler(async (req
|
||||
});
|
||||
res.json(result);
|
||||
}));
|
||||
|
||||
const exportSchema = z.object({ passphrase: z.string().min(8) });
|
||||
|
||||
settingsRouter.post("/export", requireRole("admin"), asyncHandler(async (req, res) => {
|
||||
const parsed = exportSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
|
||||
const payload = await buildExportPayload();
|
||||
const file = encryptExport(payload, parsed.data.passphrase);
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "settings",
|
||||
action: "export_config",
|
||||
detail: { integrations: payload.integrations.length, dnsProviders: payload.dnsProviders.length },
|
||||
});
|
||||
|
||||
res.json(file);
|
||||
}));
|
||||
|
||||
const encryptedFileSchema = z.object({
|
||||
app: z.literal("homelab-manager-backup"),
|
||||
version: z.literal(1),
|
||||
salt: z.string().min(1),
|
||||
iv: z.string().min(1),
|
||||
authTag: z.string().min(1),
|
||||
ciphertext: z.string().min(1),
|
||||
});
|
||||
|
||||
const importSchema = z.object({ passphrase: z.string().min(1), file: encryptedFileSchema });
|
||||
|
||||
settingsRouter.post("/import", requireRole("admin"), asyncHandler(async (req, res) => {
|
||||
const parsed = importSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = decryptExport(parsed.data.file as EncryptedExportFile, parsed.data.passphrase);
|
||||
} catch {
|
||||
return res.status(400).json({ error: "decrypt_failed", message: "Wrong passphrase, or the file is corrupted." });
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== "object" || !Array.isArray(payload.integrations) || !Array.isArray(payload.dnsProviders) || !payload.settings) {
|
||||
return res.status(400).json({ error: "invalid_payload", message: "Decrypted file doesn't look like a Homelab Manager backup." });
|
||||
}
|
||||
|
||||
const result = await applyImportPayload(payload);
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "settings",
|
||||
action: "import_config",
|
||||
detail: result,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
}));
|
||||
@@ -0,0 +1,189 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { integrations, integrationCredentials, dnsProviders, type IntegrationType, type DnsProviderType } from "../db/schema.js";
|
||||
import { encryptSecret, decryptSecret } from "../crypto.js";
|
||||
import { getSettings, updateSettings, type AppSettings } from "./settingsStore.js";
|
||||
import { resolveBaseUrl } from "../integrations/fieldSchemas.js";
|
||||
|
||||
const ALGO = "aes-256-gcm";
|
||||
const SCRYPT_KEYLEN = 32;
|
||||
|
||||
export interface EncryptedExportFile {
|
||||
app: "homelab-manager-backup";
|
||||
version: 1;
|
||||
salt: string;
|
||||
iv: string;
|
||||
authTag: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
export interface ExportPayload {
|
||||
exportedAt: string;
|
||||
settings: AppSettings;
|
||||
integrations: {
|
||||
type: IntegrationType;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
config: Record<string, string | boolean>;
|
||||
secretFields: Record<string, string | boolean>;
|
||||
}[];
|
||||
dnsProviders: {
|
||||
providerType: DnsProviderType;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
config: Record<string, string | boolean>;
|
||||
secretFields: Record<string, string | boolean>;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function decryptCredential(credentialId: number | null): Promise<Record<string, string | boolean>> {
|
||||
if (!credentialId) return {};
|
||||
const [cred] = await db.select().from(integrationCredentials).where(eq(integrationCredentials.id, credentialId)).limit(1);
|
||||
if (!cred) return {};
|
||||
return JSON.parse(decryptSecret(cred.encryptedSecret));
|
||||
}
|
||||
|
||||
/** Gathers every integration, DNS provider (with credentials decrypted), and app setting into one exportable payload. */
|
||||
export async function buildExportPayload(): Promise<ExportPayload> {
|
||||
const settings = await getSettings();
|
||||
|
||||
const integrationRows = await db.select().from(integrations);
|
||||
const exportedIntegrations = await Promise.all(
|
||||
integrationRows.map(async (row) => ({
|
||||
type: row.type,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
config: row.config ? JSON.parse(row.config) : {},
|
||||
secretFields: await decryptCredential(row.credentialId),
|
||||
})),
|
||||
);
|
||||
|
||||
const providerRows = await db.select().from(dnsProviders);
|
||||
const exportedProviders = await Promise.all(
|
||||
providerRows.map(async (row) => ({
|
||||
providerType: row.providerType,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
config: row.config ? JSON.parse(row.config) : {},
|
||||
secretFields: await decryptCredential(row.credentialId),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
exportedAt: new Date().toISOString(),
|
||||
settings,
|
||||
integrations: exportedIntegrations,
|
||||
dnsProviders: exportedProviders,
|
||||
};
|
||||
}
|
||||
|
||||
/** Encrypts an export payload with a user-chosen passphrase (scrypt-derived key, AES-256-GCM) so the file is portable across instances with different CREDENTIALS_ENCRYPTION_KEY values. */
|
||||
export function encryptExport(payload: ExportPayload, passphrase: string): EncryptedExportFile {
|
||||
const salt = randomBytes(16);
|
||||
const key = scryptSync(passphrase, salt, SCRYPT_KEYLEN);
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(ALGO, key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return {
|
||||
app: "homelab-manager-backup",
|
||||
version: 1,
|
||||
salt: salt.toString("hex"),
|
||||
iv: iv.toString("hex"),
|
||||
authTag: authTag.toString("hex"),
|
||||
ciphertext: ciphertext.toString("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reverses encryptExport(). Throws (GCM auth failure) if the passphrase is wrong or the file was tampered with/corrupted. */
|
||||
export function decryptExport(file: EncryptedExportFile, passphrase: string): ExportPayload {
|
||||
const salt = Buffer.from(file.salt, "hex");
|
||||
const key = scryptSync(passphrase, salt, SCRYPT_KEYLEN);
|
||||
const decipher = createDecipheriv(ALGO, key, Buffer.from(file.iv, "hex"));
|
||||
decipher.setAuthTag(Buffer.from(file.authTag, "hex"));
|
||||
const plaintext = Buffer.concat([decipher.update(Buffer.from(file.ciphertext, "hex")), decipher.final()]);
|
||||
return JSON.parse(plaintext.toString("utf8"));
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
integrationsCreated: number;
|
||||
integrationsSkipped: string[];
|
||||
dnsProvidersCreated: number;
|
||||
dnsProvidersSkipped: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an imported payload: settings are merged onto current settings
|
||||
* (same per-key merge as a normal settings update); integrations and DNS
|
||||
* providers are only created when no existing row shares their type/name —
|
||||
* an import never overwrites or deletes an existing integration, so it's
|
||||
* safe to re-run against a live instance without risking a working
|
||||
* credential you didn't mean to touch.
|
||||
*/
|
||||
export async function applyImportPayload(payload: ExportPayload): Promise<ImportResult> {
|
||||
await updateSettings(payload.settings);
|
||||
|
||||
const result: ImportResult = { integrationsCreated: 0, integrationsSkipped: [], dnsProvidersCreated: 0, dnsProvidersSkipped: [] };
|
||||
|
||||
for (const item of payload.integrations) {
|
||||
const [existing] = await db
|
||||
.select({ id: integrations.id })
|
||||
.from(integrations)
|
||||
.where(and(eq(integrations.type, item.type), eq(integrations.name, item.name)))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
result.integrationsSkipped.push(item.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
let credentialId: number | null = null;
|
||||
if (Object.keys(item.secretFields).length > 0) {
|
||||
const [cred] = await db
|
||||
.insert(integrationCredentials)
|
||||
.values({ name: `${item.type}:${item.name}`, encryptedSecret: encryptSecret(JSON.stringify(item.secretFields)) })
|
||||
.returning();
|
||||
credentialId = cred.id;
|
||||
}
|
||||
await db.insert(integrations).values({
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
baseUrl: resolveBaseUrl(item.type, item.config),
|
||||
credentialId,
|
||||
config: JSON.stringify(item.config),
|
||||
enabled: item.enabled,
|
||||
});
|
||||
result.integrationsCreated++;
|
||||
}
|
||||
|
||||
for (const item of payload.dnsProviders) {
|
||||
const [existing] = await db
|
||||
.select({ id: dnsProviders.id })
|
||||
.from(dnsProviders)
|
||||
.where(and(eq(dnsProviders.providerType, item.providerType), eq(dnsProviders.name, item.name)))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
result.dnsProvidersSkipped.push(item.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
let credentialId: number | null = null;
|
||||
if (Object.keys(item.secretFields).length > 0) {
|
||||
const [cred] = await db
|
||||
.insert(integrationCredentials)
|
||||
.values({ name: `${item.providerType}:${item.name}`, encryptedSecret: encryptSecret(JSON.stringify(item.secretFields)) })
|
||||
.returning();
|
||||
credentialId = cred.id;
|
||||
}
|
||||
await db.insert(dnsProviders).values({
|
||||
providerType: item.providerType,
|
||||
name: item.name,
|
||||
credentialId,
|
||||
config: JSON.stringify(item.config),
|
||||
enabled: item.enabled,
|
||||
});
|
||||
result.dnsProvidersCreated++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import BadgeSettings from "./pages/settings/BadgeSettings";
|
||||
import DisplaySettings from "./pages/settings/DisplaySettings";
|
||||
import CacheSettings from "./pages/settings/CacheSettings";
|
||||
import LogSettings from "./pages/settings/LogSettings";
|
||||
import BackupSettings from "./pages/settings/BackupSettings";
|
||||
import AppShell from "./layout/AppShell";
|
||||
import { setDateTimeSettings } from "./utils/date";
|
||||
import { setPageSize } from "./utils/pageSize";
|
||||
@@ -128,6 +129,7 @@ export default function App() {
|
||||
<Route path="display" element={<DisplaySettings />} />
|
||||
<Route path="cache" element={<CacheSettings />} />
|
||||
<Route path="logs" element={<LogSettings />} />
|
||||
<Route path="backup" element={<BackupSettings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AppShell>
|
||||
|
||||
@@ -175,6 +175,22 @@ export interface AppSettings {
|
||||
|
||||
export type AppSettingsPatch = { [K in keyof AppSettings]?: Partial<AppSettings[K]> };
|
||||
|
||||
export interface EncryptedExportFile {
|
||||
app: "homelab-manager-backup";
|
||||
version: 1;
|
||||
salt: string;
|
||||
iv: string;
|
||||
authTag: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
integrationsCreated: number;
|
||||
integrationsSkipped: string[];
|
||||
dnsProvidersCreated: number;
|
||||
dnsProvidersSkipped: string[];
|
||||
}
|
||||
|
||||
export interface DnsProviderField {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -803,5 +819,9 @@ export const api = {
|
||||
request<{ ok: true }>("/api/settings/test-webhook", { method: "POST", body: JSON.stringify(data) }),
|
||||
purgeLogs: () =>
|
||||
request<{ diagDeleted: number; auditDeleted: number }>("/api/settings/purge-logs", { method: "POST" }),
|
||||
exportConfig: (passphrase: string) =>
|
||||
request<EncryptedExportFile>("/api/settings/export", { method: "POST", body: JSON.stringify({ passphrase }) }),
|
||||
importConfig: (passphrase: string, file: EncryptedExportFile) =>
|
||||
request<ImportResult>("/api/settings/import", { method: "POST", body: JSON.stringify({ passphrase, file }) }),
|
||||
},
|
||||
};
|
||||
@@ -6,6 +6,7 @@ const SUB_NAV = [
|
||||
{ to: "/settings/display", label: "Display" },
|
||||
{ to: "/settings/cache", label: "Cache" },
|
||||
{ to: "/settings/logs", label: "Logs" },
|
||||
{ to: "/settings/backup", label: "Backup" },
|
||||
];
|
||||
|
||||
export default function Settings() {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { api, type EncryptedExportFile, type ImportResult } from "../../api/client";
|
||||
|
||||
function downloadJson(filename: string, data: unknown) {
|
||||
const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export default function BackupSettings() {
|
||||
const [exportPassphrase, setExportPassphrase] = useState("");
|
||||
const [exportPassphraseConfirm, setExportPassphraseConfirm] = useState("");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [exported, setExported] = useState(false);
|
||||
|
||||
const [importPassphrase, setImportPassphrase] = useState("");
|
||||
const [importFile, setImportFile] = useState<EncryptedExportFile | null>(null);
|
||||
const [importFileName, setImportFileName] = useState("");
|
||||
const [importFileError, setImportFileError] = useState<string | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const passphraseMismatch = exportPassphraseConfirm.length > 0 && exportPassphrase !== exportPassphraseConfirm;
|
||||
|
||||
async function handleExport() {
|
||||
setExporting(true);
|
||||
setExportError(null);
|
||||
setExported(false);
|
||||
try {
|
||||
const file = await api.settings.exportConfig(exportPassphrase);
|
||||
downloadJson(`homelab-manager-backup-${new Date().toISOString().slice(0, 10)}.json`, file);
|
||||
setExported(true);
|
||||
setExportPassphrase("");
|
||||
setExportPassphraseConfirm("");
|
||||
setTimeout(() => setExported(false), 5000);
|
||||
} catch (err) {
|
||||
setExportError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilePicked(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const picked = e.target.files?.[0];
|
||||
setImportFile(null);
|
||||
setImportFileError(null);
|
||||
setImportResult(null);
|
||||
if (!picked) return;
|
||||
setImportFileName(picked.name);
|
||||
picked
|
||||
.text()
|
||||
.then((text) => {
|
||||
const parsed = JSON.parse(text);
|
||||
setImportFile(parsed);
|
||||
})
|
||||
.catch(() => setImportFileError("That file isn't valid JSON."));
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!importFile) return;
|
||||
if (!confirm("Import this backup? Existing integrations and DNS providers with the same name are left untouched — only new ones are added.")) return;
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
setImportResult(null);
|
||||
try {
|
||||
const result = await api.settings.importConfig(importPassphrase, importFile);
|
||||
setImportResult(result);
|
||||
setImportPassphrase("");
|
||||
setImportFile(null);
|
||||
setImportFileName("");
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
} catch (err) {
|
||||
setImportError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="mb-3">Backup</h3>
|
||||
|
||||
<div className="row row-cards">
|
||||
<div className="col-md-6">
|
||||
<div className="card h-100">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Export configuration</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary">
|
||||
Downloads every integration, DNS provider, and app setting (notifications, badge colors, display,
|
||||
log retention) as one file — including credentials, decrypted and re-encrypted with the
|
||||
passphrase below so the file is portable to a fresh install with a different encryption key.
|
||||
Anyone with the file <strong>and</strong> the passphrase can read those credentials, so store it
|
||||
somewhere you trust and don't lose the passphrase — it can't be recovered.
|
||||
</p>
|
||||
<p className="text-secondary">Not included: DNS records, secrets, IPAM, servers, or the audit/diagnostic logs — this is configuration only.</p>
|
||||
{exportError && <div className="alert alert-danger">{exportError}</div>}
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Passphrase</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
minLength={8}
|
||||
value={exportPassphrase}
|
||||
onChange={(e) => setExportPassphrase(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Confirm passphrase</label>
|
||||
<input
|
||||
type="password"
|
||||
className={`form-control ${passphraseMismatch ? "is-invalid" : ""}`}
|
||||
value={exportPassphraseConfirm}
|
||||
onChange={(e) => setExportPassphraseConfirm(e.target.value)}
|
||||
/>
|
||||
{passphraseMismatch && <div className="invalid-feedback">Passphrases don't match.</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer d-flex align-items-center gap-2">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleExport}
|
||||
disabled={exporting || exportPassphrase.length < 8 || exportPassphrase !== exportPassphraseConfirm}
|
||||
>
|
||||
{exporting ? "Exporting…" : "Export configuration"}
|
||||
</button>
|
||||
{exported && <span className="text-success small">✓ Downloaded</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-md-6">
|
||||
<div className="card h-100">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Import configuration</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p className="text-secondary">
|
||||
Restores from a backup file. Settings (notifications, badges, display, log retention) are merged
|
||||
onto the current ones. Integrations and DNS providers are only <strong>added</strong> — anything
|
||||
that already exists with the same name is left alone, so this is safe to run more than once.
|
||||
</p>
|
||||
{importError && <div className="alert alert-danger">{importError}</div>}
|
||||
{importFileError && <div className="alert alert-danger">{importFileError}</div>}
|
||||
{importResult && (
|
||||
<div className="alert alert-success">
|
||||
<div>
|
||||
Integrations: {importResult.integrationsCreated} added
|
||||
{importResult.integrationsSkipped.length > 0 && `, ${importResult.integrationsSkipped.length} skipped (already exist: ${importResult.integrationsSkipped.join(", ")})`}
|
||||
</div>
|
||||
<div>
|
||||
DNS providers: {importResult.dnsProvidersCreated} added
|
||||
{importResult.dnsProvidersSkipped.length > 0 && `, ${importResult.dnsProvidersSkipped.length} skipped (already exist: ${importResult.dnsProvidersSkipped.join(", ")})`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Backup file</label>
|
||||
<input ref={fileInputRef} type="file" accept="application/json,.json" className="form-control" onChange={handleFilePicked} />
|
||||
{importFileName && <div className="form-hint">Selected: {importFileName}</div>}
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<label className="form-label">Passphrase</label>
|
||||
<input
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={importPassphrase}
|
||||
onChange={(e) => setImportPassphrase(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer">
|
||||
<button className="btn btn-primary" onClick={handleImport} disabled={importing || !importFile || !importPassphrase}>
|
||||
{importing ? "Importing…" : "Import configuration"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user