From b0861ddc6b6b187defe939fc9bdacd7eacd009dc Mon Sep 17 00:00:00 2001 From: nfebe Date: Tue, 7 Jul 2026 18:31:40 +0100 Subject: [PATCH 1/4] feat(settings): Manage S3 backup credentials and destinations Adds a Storage & Backups settings section to register S3-compatible object storage credentials (AWS S3, Cloudflare R2, Backblaze B2, MinIO) and configure remote backup destinations that reference them, with a per-destination reachability test before saving. Secrets are entered once and never returned. Each backup now shows where it lives (local and any remote destinations) so a mirrored or remote-only backup is visible at a glance. --- src/components/BackupsTab.vue | 29 ++ src/components/StorageBackupsSettings.test.ts | 92 ++++ src/components/StorageBackupsSettings.vue | 477 ++++++++++++++++++ src/services/api.ts | 42 ++ src/views/SettingsView.test.ts | 5 +- src/views/SettingsView.vue | 9 +- 6 files changed, 651 insertions(+), 3 deletions(-) create mode 100644 src/components/StorageBackupsSettings.test.ts create mode 100644 src/components/StorageBackupsSettings.vue diff --git a/src/components/BackupsTab.vue b/src/components/BackupsTab.vue index f1f3d5b..a37bfd3 100644 --- a/src/components/BackupsTab.vue +++ b/src/components/BackupsTab.vue @@ -36,6 +36,16 @@ {{ formatBytes(backup.size) }} {{ formatDate(backup.created_at) }} {{ backup.status }} + + + {{ loc }} +
@@ -586,6 +596,25 @@ onUnmounted(() => { color: var(--color-danger-700); } +.backup-location { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 6px; + border-radius: var(--radius-sm); + font-size: var(--text-xs); +} + +.backup-location.local { + background: var(--surface-inset); + color: var(--text-muted); +} + +.backup-location.remote { + background: var(--color-primary-100); + color: var(--color-primary-700); +} + .backup-components { display: flex; gap: var(--space-1); diff --git a/src/components/StorageBackupsSettings.test.ts b/src/components/StorageBackupsSettings.test.ts new file mode 100644 index 0000000..b4be674 --- /dev/null +++ b/src/components/StorageBackupsSettings.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import { createTestingPinia } from "@pinia/testing"; +import StorageBackupsSettings from "./StorageBackupsSettings.vue"; +import { storageCredentialsApi, backupDestinationsApi, configApi } from "@/services/api"; +import { useAuthStore } from "@/stores/auth"; + +vi.mock("@/services/api", () => ({ + storageCredentialsApi: { + list: vi.fn().mockResolvedValue({ data: { credentials: [] } }), + create: vi.fn().mockResolvedValue({ data: { credential: { id: "c1" } } }), + delete: vi.fn().mockResolvedValue({ data: {} }), + }, + backupDestinationsApi: { + test: vi.fn().mockResolvedValue({ data: { success: true, message: "ok" } }), + }, + configApi: { + get: vi.fn().mockResolvedValue({ data: { entry: { value: [] }, runtime: false } }), + set: vi.fn().mockResolvedValue({ data: { applied: true } }), + }, +})); + +const mockCredList = storageCredentialsApi.list as ReturnType; +const mockCredCreate = storageCredentialsApi.create as ReturnType; +const mockConfigGet = configApi.get as ReturnType; +const mockConfigSet = configApi.set as ReturnType; + +function mountComp() { + const pinia = createTestingPinia({ createSpy: vi.fn }); + const auth = useAuthStore(pinia); + (auth.hasPermission as ReturnType).mockReturnValue(true); + return mount(StorageBackupsSettings, { global: { plugins: [pinia] } }); +} + +describe("StorageBackupsSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCredList.mockResolvedValue({ + data: { credentials: [{ id: "c1", name: "prod-r2", kind: "s3", data: { access_key_id: "AKIA" } }] }, + }); + mockConfigGet.mockResolvedValue({ + data: { + entry: { + value: [{ name: "s3-prod", type: "s3", bucket: "b", credential_id: "c1", use_path_style: false }], + }, + runtime: false, + }, + }); + }); + + it("loads credentials and destinations on mount", async () => { + const wrapper = mountComp(); + await flushPromises(); + expect(mockCredList).toHaveBeenCalledWith("s3"); + expect(mockConfigGet).toHaveBeenCalledWith("backup.destinations"); + expect((wrapper.vm as any).creds).toHaveLength(1); + expect((wrapper.vm as any).dests).toHaveLength(1); + // Destinations without an explicit enabled flag default to enabled. + expect((wrapper.vm as any).dests[0].enabled).toBe(true); + }); + + it("creates a credential through the storage credentials API", async () => { + const wrapper = mountComp(); + await flushPromises(); + const vm = wrapper.vm as any; + vm.credForm.name = "backblaze"; + vm.credForm.access_key_id = "keyid"; + vm.credForm.secret_access_key = "secret"; + await vm.addCredential(); + expect(mockCredCreate).toHaveBeenCalledWith({ + name: "backblaze", + kind: "s3", + data: { access_key_id: "keyid", secret_access_key: "secret" }, + }); + }); + + it("persists destinations through the config API", async () => { + const wrapper = mountComp(); + await flushPromises(); + const vm = wrapper.vm as any; + await vm.saveDestinations(); + expect(mockConfigSet).toHaveBeenCalledWith("backup.destinations", vm.dests); + }); + + it("tests a destination and reports reachability", async () => { + const wrapper = mountComp(); + await flushPromises(); + const vm = wrapper.vm as any; + await vm.testDestination(vm.dests[0], 0); + expect(backupDestinationsApi.test).toHaveBeenCalledWith(vm.dests[0]); + }); +}); diff --git a/src/components/StorageBackupsSettings.vue b/src/components/StorageBackupsSettings.vue new file mode 100644 index 0000000..ec50428 --- /dev/null +++ b/src/components/StorageBackupsSettings.vue @@ -0,0 +1,477 @@ + + + + + diff --git a/src/services/api.ts b/src/services/api.ts index b9bd698..e9bb7b8 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -931,6 +931,47 @@ export const credentialsApi = { test: (id: string) => apiClient.post<{ message: string; success: boolean }>(`/credentials/${id}/test`), }; +export type CredentialKind = "s3"; + +export interface StorageCredential { + readonly id: string; + readonly name: string; + readonly kind: CredentialKind; + readonly data: Record; + readonly created_at: string; + readonly updated_at: string; +} + +export interface BackupDestination { + name: string; + type: string; + endpoint: string; + region: string; + bucket: string; + prefix: string; + credential_id: string; + use_path_style: boolean; + enabled?: boolean | null; +} + +export const storageCredentialsApi = { + list: (kind?: CredentialKind) => + apiClient.get<{ credentials: StorageCredential[] }>("/storage-credentials", { + params: kind ? { kind } : undefined, + }), + create: (data: { name: string; kind: CredentialKind; data: Record }) => + apiClient.post<{ message: string; credential: StorageCredential }>("/storage-credentials", data), + update: (id: string, data: { name?: string; data?: Record }) => + apiClient.put<{ message: string; credential: StorageCredential }>(`/storage-credentials/${id}`, data), + delete: (id: string) => apiClient.delete(`/storage-credentials/${id}`), +}; + +export const backupDestinationsApi = { + list: () => apiClient.get<{ destinations: BackupDestination[] }>("/backup-destinations"), + test: (data: BackupDestination) => + apiClient.post<{ success: boolean; message?: string; error?: string }>("/backup-destinations/test", data), +}; + export interface SecurityEventFilter { event_type?: string; severity?: string; @@ -1139,6 +1180,7 @@ export interface Backup { readonly created_at: string; readonly completed_at?: string; readonly expires_at?: string; + readonly locations?: readonly string[]; } export interface BackupSpec { diff --git a/src/views/SettingsView.test.ts b/src/views/SettingsView.test.ts index 5424551..073a700 100644 --- a/src/views/SettingsView.test.ts +++ b/src/views/SettingsView.test.ts @@ -97,10 +97,10 @@ describe("SettingsView", () => { }); describe("Tab navigation", () => { - it("displays all nine tabs", () => { + it("displays all ten tabs", () => { const wrapper = mountView(); const tabs = wrapper.findAll(".tab"); - expect(tabs.length).toBe(9); + expect(tabs.length).toBe(10); }); it("has General tab", () => { @@ -382,6 +382,7 @@ describe("SettingsView", () => { { id: "healthchecks", label: "Health Checks", icon: "pi pi-heart" }, { id: "notifications", label: "Notifications", icon: "" }, { id: "credentials", label: "Credentials", icon: "pi pi-key" }, + { id: "storage", label: "Storage & Backups", icon: "" }, { id: "ai", label: "AI Assistant", icon: "" }, ]); }); diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue index 638e65f..3e078a9 100644 --- a/src/views/SettingsView.vue +++ b/src/views/SettingsView.vue @@ -775,6 +775,10 @@
+
+ +
+
@@ -1015,7 +1019,7 @@ diff --git a/src/components/base/BaseCard.vue b/src/components/base/BaseCard.vue new file mode 100644 index 0000000..2380c27 --- /dev/null +++ b/src/components/base/BaseCard.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/src/components/base/BaseField.vue b/src/components/base/BaseField.vue new file mode 100644 index 0000000..d81299c --- /dev/null +++ b/src/components/base/BaseField.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/src/components/base/BaseInput.vue b/src/components/base/BaseInput.vue new file mode 100644 index 0000000..1cc641c --- /dev/null +++ b/src/components/base/BaseInput.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/components/base/BaseSelect.vue b/src/components/base/BaseSelect.vue new file mode 100644 index 0000000..76bec70 --- /dev/null +++ b/src/components/base/BaseSelect.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/src/components/base/BaseTextarea.vue b/src/components/base/BaseTextarea.vue new file mode 100644 index 0000000..9ba8f99 --- /dev/null +++ b/src/components/base/BaseTextarea.vue @@ -0,0 +1,53 @@ +