From 7fd105b2535b98b8151f7e6a13b215c9aa57b0f7 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 11 Aug 2026 14:29:30 +0300 Subject: [PATCH 1/7] OSAC-3600: add StorageBackendCreatePage Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/i18n/locales/en/translation.json | 9 + .../admin/StorageBackendCreatePage.test.tsx | 167 +++++++++++++++ .../pages/admin/StorageBackendCreatePage.tsx | 196 ++++++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx create mode 100644 libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 8ebfe198..9534fbae 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -94,6 +94,7 @@ "catalogProvision.vm.wizardDescription": "Select a catalog item, configure, and provision.", "catalogProvision.vm.wizardTitle": "Create virtual machine", "catalogProvision.wizard.navAria": "{{title}} steps", + "Ceph": "Ceph", "CIDR": "CIDR", "CIDR must be within parent virtual network range": "CIDR must be within parent virtual network range", "CIDR overlaps with existing subnet \"{{name}}\" ({{cidr}})": "CIDR overlaps with existing subnet \"{{name}}\" ({{cidr}})", @@ -185,6 +186,7 @@ "Enable {{idpName}}?": "Enable {{idpName}}?", "Encryption enabled": "Encryption enabled", "Endpoint": "Endpoint", + "Endpoint is required": "Endpoint is required", "Error": "Error", "Error loading external IP pools": "Error loading external IP pools", "Error loading virtual networks": "Error loading virtual networks", @@ -204,6 +206,7 @@ "Failed to connect to the console.": "Failed to connect to the console.", "Failed to create resource": "Failed to create resource", "Failed to create role binding": "Failed to create role binding", + "Failed to create storage backend": "Failed to create storage backend", "Failed to create storage tier": "Failed to create storage tier", "Failed to create subnet": "Failed to create subnet", "Failed to create tenant": "Failed to create tenant", @@ -348,6 +351,7 @@ "Overview": "Overview", "Parent virtual network": "Parent virtual network", "Password": "Password", + "Password is required": "Password is required", "Paste a public SSH key for remote access. Supported types: ssh-rsa, ssh-ed25519, and ecdsa-sha2-nistp256/384/521.": "Paste a public SSH key for remote access. Supported types: ssh-rsa, ssh-ed25519, and ecdsa-sha2-nistp256/384/521.", "Paste from clipboard": "Paste from clipboard", "Paused": "Paused", @@ -368,6 +372,8 @@ "Protocol is required": "Protocol is required", "Protocol(s)": "Protocol(s)", "Provider": "Provider", + "Provider is required": "Provider is required", + "Provider must be one of vast, ceph, or pure": "Provider must be one of vast, ceph, or pure", "Provision a bare metal instance from a catalog item.": "Provision a bare metal instance from a catalog item.", "Provision bare metal": "Provision bare metal", "Provisioning": "Provisioning", @@ -376,6 +382,7 @@ "Pull secret": "Pull secret", "Pull secret is required": "Pull secret is required", "Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).": "Pull secrets download OpenShift components and connect clusters to your Red Hat account. Copy the full JSON from OpenShift Cluster Manager (console.redhat.com/openshift/install/pull-secret).", + "Pure": "Pure", "Quota (GiB)": "Quota (GiB)", "Reactivate": "Reactivate", "Ready": "Ready", @@ -475,7 +482,9 @@ "User data must not exceed 64 KB.": "User data must not exceed 64 KB.", "User info URL": "User info URL", "Username": "Username", + "Username is required": "Username is required", "Users": "Users", + "VAST": "VAST", "View and manage your bare metal instances.": "View and manage your bare metal instances.", "View password": "View password", "Virtual machine conditions": "Virtual machine conditions", diff --git a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx new file mode 100644 index 00000000..6a470d36 --- /dev/null +++ b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx @@ -0,0 +1,167 @@ +import { create } from '@bufbuild/protobuf'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + type StorageBackendsCreateRequest, + StorageBackendsCreateResponseSchema, +} from '@osac/types/private'; + +import { StorageBackendCreatePage } from './StorageBackendCreatePage'; +import type { MockTransportOverrides } from '../../test-utils/createMockConnectTransport'; +import { renderWithProviders } from '../../test-utils/TestProviders'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigate: () => mockNavigate, + }; +}); + +const renderPage = (overrides?: MockTransportOverrides) => + renderWithProviders(, { + transportOverrides: overrides, + }); + +describe('StorageBackendCreatePage', () => { + beforeEach(() => { + mockNavigate.mockReset(); + }); + + const fillValidForm = async (user: ReturnType['user']) => { + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'vast-prod-1'); + await user.click(screen.getByLabelText(/^Provider/)); + await user.click(screen.getByRole('option', { name: 'VAST' })); + await user.type(screen.getByRole('textbox', { name: 'Endpoint' }), 'vast.example.com:443'); + await user.type(screen.getByLabelText(/^Username/), 'admin'); + await user.type(screen.getByLabelText(/^Password/), 'super-secret'); + }; + + it('renders the page title, breadcrumb, and all fields', () => { + renderPage(); + + expect(screen.getByRole('heading', { name: 'Create storage backend' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Storage backends' })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Name' })).toBeInTheDocument(); + expect(screen.getByLabelText(/^Provider/)).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Endpoint' })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument(); + expect(screen.getByLabelText(/^Username/)).toBeInTheDocument(); + expect(screen.getByLabelText(/^Password/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('renders the provider select with exactly vast, ceph, and pure options', async () => { + const { user } = renderPage(); + + await user.click(screen.getByLabelText(/^Provider/)); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(3); + expect(options.map((option) => option.textContent)).toEqual(['VAST', 'Ceph', 'Pure']); + }); + + it('renders the password field as a masked input', () => { + renderPage(); + + const passwordField = screen.getByLabelText(/^Password/); + expect(passwordField).toHaveAttribute('type', 'password'); + }); + + it('shows a DNS-label validation error for an invalid name and does not submit', async () => { + const onStorageBackendCreate = vi.fn(); + const { user } = renderPage({ onStorageBackendCreate }); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'Invalid_Name'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect( + screen.getByText( + 'Name must only contain lowercase letters (a-z), digits (0-9), and hyphens (-)', + ), + ).toBeInTheDocument(); + }); + expect(onStorageBackendCreate).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('shows required-field validation errors for endpoint, username, and password', async () => { + const onStorageBackendCreate = vi.fn(); + const { user } = renderPage({ onStorageBackendCreate }); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'vast-prod-1'); + await user.click(screen.getByLabelText(/^Provider/)); + await user.click(screen.getByRole('option', { name: 'VAST' })); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(screen.getByText('Endpoint is required')).toBeInTheDocument(); + }); + expect(screen.getByText('Username is required')).toBeInTheDocument(); + expect(screen.getByText('Password is required')).toBeInTheDocument(); + expect(onStorageBackendCreate).not.toHaveBeenCalled(); + }); + + it('submits the expected payload and navigates to the backends list on success', async () => { + let capturedRequest: StorageBackendsCreateRequest | undefined; + const { user } = renderPage({ + onStorageBackendCreate: (req) => { + capturedRequest = req; + return create(StorageBackendsCreateResponseSchema, { + object: { id: 'new-backend-1', metadata: req.object?.metadata, spec: req.object?.spec }, + }); + }, + }); + + await fillValidForm(user); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/backends'); + }); + + expect(capturedRequest?.object?.metadata?.name).toBe('vast-prod-1'); + expect(capturedRequest?.object?.spec?.provider).toBe('vast'); + expect(capturedRequest?.object?.spec?.endpoint).toBe('vast.example.com:443'); + expect(capturedRequest?.object?.spec?.credentials?.username).toBe('admin'); + expect(capturedRequest?.object?.spec?.credentials?.password).toBe('super-secret'); + }); + + it('shows a form-level error and does not navigate when the name already exists', async () => { + const { user } = renderPage({ + onStorageBackendCreate: () => { + throw new ConnectError('Storage backend name already exists', Code.AlreadyExists); + }, + }); + + await fillValidForm(user); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(screen.getByText('Failed to create storage backend')).toBeInTheDocument(); + }); + expect(screen.getByText('Storage backend name already exists')).toBeInTheDocument(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('navigates back to the backends list on cancel', async () => { + const { user } = renderPage(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/backends'); + }); + + it('navigates back to the backends list via breadcrumb', async () => { + const { user } = renderPage(); + + await user.click(screen.getByRole('button', { name: 'Storage backends' })); + + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/backends'); + }); +}); diff --git a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx new file mode 100644 index 00000000..3c324f7e --- /dev/null +++ b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx @@ -0,0 +1,196 @@ +import { useNavigate } from 'react-router-dom'; +import { + ActionList, + ActionListGroup, + ActionListItem, + Alert, + Breadcrumb, + BreadcrumbItem, + Button, + PageSection, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { Formik } from 'formik'; +import type { TFunction } from 'i18next'; +import * as Yup from 'yup'; + +import { useCreateStorageBackend } from '@osac/ui-components/api/v1/private/storage-backends'; +import { InputField } from '@osac/ui-components/components/Form/InputField'; +import OsacForm from '@osac/ui-components/components/Form/OsacForm'; +import { + SelectField, + type SelectFieldOption, +} from '@osac/ui-components/components/Form/SelectField'; +import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; +import { getErrorMessage } from '@osac/ui-components/utils/error'; +import { resourceNameSchema } from '@osac/ui-components/validation/resource-name'; + +const BACKENDS_LIST_PATH = '/admin/infrastructure/storage/backends'; + +const PROVIDERS = ['vast', 'ceph', 'pure'] as const; + +interface StorageBackendFormValues { + name: string; + provider: string; + endpoint: string; + description: string; + credentials: { username: string; password: string }; +} + +const initialValues: StorageBackendFormValues = { + name: '', + provider: '', + endpoint: '', + description: '', + credentials: { username: '', password: '' }, +}; + +const getStorageBackendSchema = (t: TFunction) => + Yup.object({ + name: resourceNameSchema(t), + provider: Yup.string() + .oneOf([...PROVIDERS], t('Provider must be one of vast, ceph, or pure')) + .required(t('Provider is required')), + endpoint: Yup.string().required(t('Endpoint is required')), + description: Yup.string(), + credentials: Yup.object({ + username: Yup.string().required(t('Username is required')), + password: Yup.string().required(t('Password is required')), + }), + }); + +export const StorageBackendCreatePage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { mutate, error, isPending } = useCreateStorageBackend(); + + const providerOptions: SelectFieldOption[] = [ + { value: 'vast', label: t('VAST') }, + { value: 'ceph', label: t('Ceph') }, + { value: 'pure', label: t('Pure') }, + ]; + + return ( + <> + + + + + + + {t('Create')} + + + {t('Create storage backend')} + + + + + + mutate( + { + metadata: { name: values.name }, + spec: { + provider: values.provider, + endpoint: values.endpoint, + description: values.description, + credentials: { + username: values.credentials.username, + password: values.credentials.password, + }, + }, + }, + { onSuccess: () => navigate(BACKENDS_LIST_PATH) }, + ) + } + > + {({ submitForm }) => ( + + + + + + + + + + + + + {!!error && ( + + + {getErrorMessage(error)} + + + )} + + + + + + + + + + + + + + )} + + + + ); +}; From d70c0d1729bef98d8626f1200c3daed310b59025 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 11 Aug 2026 14:30:56 +0300 Subject: [PATCH 2/7] OSAC-3600: wire StorageBackendCreatePage into storage routes Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- apps/app-frontend/src/shell/StorageRoutes.test.tsx | 5 +++-- apps/app-frontend/src/shell/StorageRoutes.tsx | 6 ++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/app-frontend/src/shell/StorageRoutes.test.tsx b/apps/app-frontend/src/shell/StorageRoutes.test.tsx index 86b61ee5..0550b11d 100644 --- a/apps/app-frontend/src/shell/StorageRoutes.test.tsx +++ b/apps/app-frontend/src/shell/StorageRoutes.test.tsx @@ -21,10 +21,11 @@ describe('StorageRoutes', () => { expect(screen.getByRole('button', { name: 'Create backend' })).toBeInTheDocument(); }); - it('renders a placeholder for backends/create', () => { + it('renders the real create form for backends/create', () => { renderAt('/admin/infrastructure/storage/backends/create'); - expect(screen.getByText('Create storage backend')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Create storage backend' })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Name' })).toBeInTheDocument(); }); it('renders a placeholder for backends/:id/edit', () => { diff --git a/apps/app-frontend/src/shell/StorageRoutes.tsx b/apps/app-frontend/src/shell/StorageRoutes.tsx index b9437858..72825216 100644 --- a/apps/app-frontend/src/shell/StorageRoutes.tsx +++ b/apps/app-frontend/src/shell/StorageRoutes.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import StorageTierCreatePage from '@osac/ui-components/components/Storage/StorageTierCreatePage'; import { useTranslation } from '@osac/ui-components/hooks/useTranslation'; +import { StorageBackendCreatePage } from '@osac/ui-components/pages/admin/StorageBackendCreatePage'; import { StorageManagementPage } from '@osac/ui-components/pages/admin/StorageManagementPage'; import { StoragePlaceholder } from '@osac/ui-components/pages/admin/StoragePlaceholder'; @@ -12,10 +13,7 @@ export const StorageRoutes = () => { } /> } /> - } - /> + } /> } From 33a0dbfc7b287184a1b0046e47161dca6b5495d2 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 11 Aug 2026 15:13:29 +0300 Subject: [PATCH 3/7] OSAC-3600: raise timeout on full-form submission tests Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../src/pages/admin/StorageBackendCreatePage.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx index 6a470d36..03642bd9 100644 --- a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx +++ b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx @@ -130,7 +130,7 @@ describe('StorageBackendCreatePage', () => { expect(capturedRequest?.object?.spec?.endpoint).toBe('vast.example.com:443'); expect(capturedRequest?.object?.spec?.credentials?.username).toBe('admin'); expect(capturedRequest?.object?.spec?.credentials?.password).toBe('super-secret'); - }); + }, 15000); it('shows a form-level error and does not navigate when the name already exists', async () => { const { user } = renderPage({ @@ -147,7 +147,7 @@ describe('StorageBackendCreatePage', () => { }); expect(screen.getByText('Storage backend name already exists')).toBeInTheDocument(); expect(mockNavigate).not.toHaveBeenCalled(); - }); + }, 15000); it('navigates back to the backends list on cancel', async () => { const { user } = renderPage(); From 34463a36e9586df2167255d5667d29ef2d2bcb92 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Tue, 11 Aug 2026 16:13:40 +0300 Subject: [PATCH 4/7] =?UTF-8?q?OSAC-3600:=20Address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20NameField=20reuse,=20duplicate-submit=20guard,=20un?= =?UTF-8?q?saved-changes=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reuse the shared NameField component for the name input, restructuring Formik values/Yup schema from a flat name to nested metadata.name. - Drop the redundant provider oneOf validator (the Select already constrains the value); this also fixes a latent bug where an empty provider showed the wrong error message instead of "Provider is required". - Switch to mutateAsync and disable Create/Cancel while Formik's isSubmitting or the mutation's isPending is true, preventing duplicate submissions. - Mount LeaveFormConfirmation to warn on navigating away with unsaved changes; generalize its copy since it's no longer wizard-only. - Extract a hardcoded test password into a named fixture constant. - Widen the mock transport's onStorageBackendCreate override type to allow an async handler, matching what the router already supports. Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../src/shell/StorageRoutes.test.tsx | 13 +++- libs/i18n/locales/en/translation.json | 5 +- .../components/Form/LeaveFormConfirmation.tsx | 4 +- .../admin/StorageBackendCreatePage.test.tsx | 60 ++++++++++++++++++- .../pages/admin/StorageBackendCreatePage.tsx | 50 +++++++--------- .../test-utils/createMockConnectTransport.ts | 4 +- 6 files changed, 100 insertions(+), 36 deletions(-) diff --git a/apps/app-frontend/src/shell/StorageRoutes.test.tsx b/apps/app-frontend/src/shell/StorageRoutes.test.tsx index 0550b11d..8aa2d014 100644 --- a/apps/app-frontend/src/shell/StorageRoutes.test.tsx +++ b/apps/app-frontend/src/shell/StorageRoutes.test.tsx @@ -1,11 +1,22 @@ import { Route, Routes } from 'react-router-dom'; import { screen, waitFor } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { renderWithProviders } from '@osac/ui-components/test-utils/TestProviders'; import { StorageRoutes } from './StorageRoutes'; +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // useBlocker requires a data router; this test harness renders under a + // plain MemoryRouter, so StorageBackendCreatePage's LeaveFormConfirmation + // is stubbed out here rather than exercised (see its own test file). + useBlocker: () => ({ state: 'unblocked' as const }), + }; +}); + const renderAt = (path: string) => renderWithProviders( diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index 9534fbae..b80d82cd 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -12,9 +12,9 @@ "All": "All", "An external IP pool is required": "An external IP pool is required", "API URL": "API URL", - "Are you sure you want to cancel? Your selections and entered data will be lost.": "Are you sure you want to cancel? Your selections and entered data will be lost.", "Are you sure you want to disable Identity provider {{idpName}}": "Are you sure you want to disable Identity provider {{idpName}}", "Are you sure you want to enable Identity provider {{idpName}}": "Are you sure you want to enable Identity provider {{idpName}}", + "Are you sure you want to leave? Your entered data will be lost.": "Are you sure you want to leave? Your entered data will be lost.", "At least one CIDR (IPv4 or IPv6) is required": "At least one CIDR (IPv4 or IPv6) is required", "At least one node set is required": "At least one node set is required", "At least one user is required": "At least one user is required", @@ -168,7 +168,7 @@ "Disable {{idpName}}?": "Disable {{idpName}}?", "Disabled": "Disabled", "Discard and close": "Discard and close", - "Discard wizard progress?": "Discard wizard progress?", + "Discard unsaved changes?": "Discard unsaved changes?", "Domain {{number}}": "Domain {{number}}", "Domain is required": "Domain is required", "Domains": "Domains", @@ -373,7 +373,6 @@ "Protocol(s)": "Protocol(s)", "Provider": "Provider", "Provider is required": "Provider is required", - "Provider must be one of vast, ceph, or pure": "Provider must be one of vast, ceph, or pure", "Provision a bare metal instance from a catalog item.": "Provision a bare metal instance from a catalog item.", "Provision bare metal": "Provision bare metal", "Provisioning": "Provisioning", diff --git a/libs/ui-components/src/components/Form/LeaveFormConfirmation.tsx b/libs/ui-components/src/components/Form/LeaveFormConfirmation.tsx index 9ecd7725..45a663ea 100644 --- a/libs/ui-components/src/components/Form/LeaveFormConfirmation.tsx +++ b/libs/ui-components/src/components/Form/LeaveFormConfirmation.tsx @@ -31,13 +31,13 @@ const LeaveFormConfirmation = () => { return ( - {t('Are you sure you want to cancel? Your selections and entered data will be lost.')} + {t('Are you sure you want to leave? Your entered data will be lost.')} diff --git a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx index 03642bd9..9541358c 100644 --- a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx +++ b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type StorageBackendsCreateRequest, + type StorageBackendsCreateResponse, StorageBackendsCreateResponseSchema, } from '@osac/types/private'; @@ -18,9 +19,16 @@ vi.mock('react-router-dom', async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate, + // useBlocker requires a data router; this test harness renders under a plain + // MemoryRouter, so LeaveFormConfirmation's blocking behavior is stubbed out + // rather than exercised here (its only other caller has no test coverage + // for it either). + useBlocker: () => ({ state: 'unblocked' as const }), }; }); +const testBackendPassword = 'test-password'; + const renderPage = (overrides?: MockTransportOverrides) => renderWithProviders(, { transportOverrides: overrides, @@ -37,7 +45,7 @@ describe('StorageBackendCreatePage', () => { await user.click(screen.getByRole('option', { name: 'VAST' })); await user.type(screen.getByRole('textbox', { name: 'Endpoint' }), 'vast.example.com:443'); await user.type(screen.getByLabelText(/^Username/), 'admin'); - await user.type(screen.getByLabelText(/^Password/), 'super-secret'); + await user.type(screen.getByLabelText(/^Password/), testBackendPassword); }; it('renders the page title, breadcrumb, and all fields', () => { @@ -107,6 +115,54 @@ describe('StorageBackendCreatePage', () => { expect(onStorageBackendCreate).not.toHaveBeenCalled(); }); + it('shows a required error for provider when nothing is selected, not the removed oneOf message', async () => { + const onStorageBackendCreate = vi.fn(); + const { user } = renderPage({ onStorageBackendCreate }); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'vast-prod-1'); + await user.type(screen.getByRole('textbox', { name: 'Endpoint' }), 'vast.example.com:443'); + await user.type(screen.getByLabelText(/^Username/), 'admin'); + await user.type(screen.getByLabelText(/^Password/), testBackendPassword); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(screen.getByText('Provider is required')).toBeInTheDocument(); + }); + expect( + screen.queryByText('Provider must be one of vast, ceph, or pure'), + ).not.toBeInTheDocument(); + expect(onStorageBackendCreate).not.toHaveBeenCalled(); + }); + + it('disables Create while the submission is pending, to prevent duplicate submissions', async () => { + let resolveCreate: (() => void) | undefined; + const onStorageBackendCreate = () => + new Promise((resolve) => { + resolveCreate = () => + resolve(create(StorageBackendsCreateResponseSchema, { object: { id: 'new-backend-1' } })); + }); + + const { user } = renderPage({ onStorageBackendCreate }); + + await fillValidForm(user); + await user.click(screen.getByRole('button', { name: 'Create' })); + + // Once isLoading is true, PatternFly's Spinner contributes its own + // "Contents" accessible name to the button, so an exact "Create" match + // no longer resolves — match by substring instead (same pattern already + // used elsewhere in this file for accessible-name additions). + await waitFor(() => { + expect(screen.getByRole('button', { name: /Create/ })).toBeDisabled(); + }); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + + resolveCreate?.(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/backends'); + }); + }, 15000); + it('submits the expected payload and navigates to the backends list on success', async () => { let capturedRequest: StorageBackendsCreateRequest | undefined; const { user } = renderPage({ @@ -129,7 +185,7 @@ describe('StorageBackendCreatePage', () => { expect(capturedRequest?.object?.spec?.provider).toBe('vast'); expect(capturedRequest?.object?.spec?.endpoint).toBe('vast.example.com:443'); expect(capturedRequest?.object?.spec?.credentials?.username).toBe('admin'); - expect(capturedRequest?.object?.spec?.credentials?.password).toBe('super-secret'); + expect(capturedRequest?.object?.spec?.credentials?.password).toBe(testBackendPassword); }, 15000); it('shows a form-level error and does not navigate when the name already exists', async () => { diff --git a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx index 3c324f7e..b2487f97 100644 --- a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx +++ b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx @@ -17,7 +17,9 @@ import type { TFunction } from 'i18next'; import * as Yup from 'yup'; import { useCreateStorageBackend } from '@osac/ui-components/api/v1/private/storage-backends'; +import NameField from '@osac/ui-components/components/catalogProvision/wizard/fields/NameField'; import { InputField } from '@osac/ui-components/components/Form/InputField'; +import LeaveFormConfirmation from '@osac/ui-components/components/Form/LeaveFormConfirmation'; import OsacForm from '@osac/ui-components/components/Form/OsacForm'; import { SelectField, @@ -29,10 +31,8 @@ import { resourceNameSchema } from '@osac/ui-components/validation/resource-name const BACKENDS_LIST_PATH = '/admin/infrastructure/storage/backends'; -const PROVIDERS = ['vast', 'ceph', 'pure'] as const; - interface StorageBackendFormValues { - name: string; + metadata: { name: string }; provider: string; endpoint: string; description: string; @@ -40,7 +40,7 @@ interface StorageBackendFormValues { } const initialValues: StorageBackendFormValues = { - name: '', + metadata: { name: '' }, provider: '', endpoint: '', description: '', @@ -49,10 +49,8 @@ const initialValues: StorageBackendFormValues = { const getStorageBackendSchema = (t: TFunction) => Yup.object({ - name: resourceNameSchema(t), - provider: Yup.string() - .oneOf([...PROVIDERS], t('Provider must be one of vast, ceph, or pure')) - .required(t('Provider is required')), + metadata: Yup.object({ name: resourceNameSchema(t) }), + provider: Yup.string().required(t('Provider is required')), endpoint: Yup.string().required(t('Endpoint is required')), description: Yup.string(), credentials: Yup.object({ @@ -64,7 +62,7 @@ const getStorageBackendSchema = (t: TFunction) => export const StorageBackendCreatePage = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const { mutate, error, isPending } = useCreateStorageBackend(); + const { mutateAsync, error, isPending } = useCreateStorageBackend(); const providerOptions: SelectFieldOption[] = [ { value: 'vast', label: t('VAST') }, @@ -93,10 +91,10 @@ export const StorageBackendCreatePage = () => { - mutate( - { - metadata: { name: values.name }, + onSubmit={async (values) => { + try { + await mutateAsync({ + metadata: values.metadata, spec: { provider: values.provider, endpoint: values.endpoint, @@ -106,21 +104,19 @@ export const StorageBackendCreatePage = () => { password: values.credentials.password, }, }, - }, - { onSuccess: () => navigate(BACKENDS_LIST_PATH) }, - ) - } + }); + navigate(BACKENDS_LIST_PATH); + } catch { + // Surfaced via the mutation's own `error` state below; nothing further to do here. + } + }} > - {({ submitForm }) => ( + {({ submitForm, isSubmitting }) => ( + - + { @@ -179,7 +175,7 @@ export const StorageBackendCreatePage = () => { diff --git a/libs/ui-components/src/test-utils/createMockConnectTransport.ts b/libs/ui-components/src/test-utils/createMockConnectTransport.ts index c7ed45fa..d791e520 100644 --- a/libs/ui-components/src/test-utils/createMockConnectTransport.ts +++ b/libs/ui-components/src/test-utils/createMockConnectTransport.ts @@ -172,7 +172,9 @@ export type MockTransportOverrides = { onStorageBackendList?: ( req: StorageBackendsListRequest, ) => MessageInitShape; - onStorageBackendCreate?: (req: StorageBackendsCreateRequest) => StorageBackendsCreateResponse; + onStorageBackendCreate?: ( + req: StorageBackendsCreateRequest, + ) => StorageBackendsCreateResponse | Promise; onStorageBackendUpdate?: (req: StorageBackendsUpdateRequest) => StorageBackendsUpdateResponse; onStorageTierList?: ( req: StorageTiersListRequest, From aeb6c9dc7ba594dd0e5ee28349567287c56c493c Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Wed, 12 Aug 2026 12:06:55 +0300 Subject: [PATCH 5/7] NO-ISSUE: align StorageTierCreatePage with StorageBackendCreatePage conventions - Switch to mutateAsync and disable Create/Cancel while Formik's isSubmitting or the mutation's isPending is true, preventing duplicate submissions. - Reuse the shared NameField component, restructuring Formik values/Yup schema from a flat name to nested metadata.name. - Mount LeaveFormConfirmation to warn on navigating away with unsaved changes. - Widen the mock transport's onStorageTierCreate override type to allow an async handler, matching onStorageBackendCreate and what the router already supports. - Add a test verifying the Create/Cancel buttons disable during a pending submission. Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../Storage/StorageTierCreatePage.test.tsx | 45 ++++++++++++++++++- .../Storage/StorageTierCreatePage.tsx | 39 +++++++++------- .../test-utils/createMockConnectTransport.ts | 4 +- 3 files changed, 68 insertions(+), 20 deletions(-) diff --git a/libs/ui-components/src/components/Storage/StorageTierCreatePage.test.tsx b/libs/ui-components/src/components/Storage/StorageTierCreatePage.test.tsx index 0427030a..83a9da00 100644 --- a/libs/ui-components/src/components/Storage/StorageTierCreatePage.test.tsx +++ b/libs/ui-components/src/components/Storage/StorageTierCreatePage.test.tsx @@ -1,9 +1,14 @@ +import { create } from '@bufbuild/protobuf'; import { Code, ConnectError } from '@connectrpc/connect'; import { screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { StorageBackend } from '@osac/types/private'; -import { StorageBackendState, StorageProtocol } from '@osac/types/private'; +import type { StorageBackend, StorageTiersCreateResponse } from '@osac/types/private'; +import { + StorageBackendState, + StorageProtocol, + StorageTiersCreateResponseSchema, +} from '@osac/types/private'; import StorageTierCreatePage from './StorageTierCreatePage'; import { renderWithProviders } from '../../test-utils/TestProviders'; @@ -14,6 +19,11 @@ vi.mock('react-router-dom', async (importOriginal) => { return { ...actual, useNavigate: () => mockNavigate, + // useBlocker requires a data router; this test harness renders under a plain + // MemoryRouter, so LeaveFormConfirmation's blocking behavior is stubbed out + // rather than exercised here (see StorageBackendCreatePage.test.tsx for the + // same pattern). + useBlocker: () => ({ state: 'unblocked' as const }), }; }); @@ -245,6 +255,37 @@ describe('StorageTierCreatePage', () => { }); }); + it('disables Create while the submission is pending, to prevent duplicate submissions', async () => { + let resolveCreate: (() => void) | undefined; + const onStorageTierCreate = () => + new Promise((resolve) => { + resolveCreate = () => + resolve(create(StorageTiersCreateResponseSchema, { object: { id: 'new-tier-1' } })); + }); + + const { user } = renderWithProviders(, { + apiFixtures: { storageBackends: [readyBackend] }, + transportOverrides: { onStorageTierCreate }, + }); + + await fillValidForm(user); + await user.click(screen.getByRole('button', { name: 'Create' })); + + // Once isLoading is true, PatternFly's Spinner contributes its own "Contents" + // accessible name to the button, so an exact "Create" match no longer + // resolves — match by substring instead. + await waitFor(() => { + expect(screen.getByRole('button', { name: /Create/ })).toBeDisabled(); + }); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + + resolveCreate?.(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/tiers'); + }); + }, 15000); + it('shows the ALREADY_EXISTS error as a form-level error without navigating away', async () => { const { user } = renderWithProviders(, { apiFixtures: { storageBackends: [readyBackend] }, diff --git a/libs/ui-components/src/components/Storage/StorageTierCreatePage.tsx b/libs/ui-components/src/components/Storage/StorageTierCreatePage.tsx index b8b8267b..85ff28c7 100644 --- a/libs/ui-components/src/components/Storage/StorageTierCreatePage.tsx +++ b/libs/ui-components/src/components/Storage/StorageTierCreatePage.tsx @@ -26,8 +26,10 @@ import { useTranslation } from '../../hooks/useTranslation'; import { getErrorMessage } from '../../utils/error'; import { positiveIntegerSchema } from '../../validation/positive-integer'; import { resourceNameSchema } from '../../validation/resource-name'; +import NameField from '../catalogProvision/wizard/fields/NameField'; import { CheckboxField } from '../Form/CheckboxField'; import { InputField } from '../Form/InputField'; +import LeaveFormConfirmation from '../Form/LeaveFormConfirmation'; import OsacForm from '../Form/OsacForm'; import { RadioButtonField } from '../Form/RadioButtonField'; import { SelectField } from '../Form/SelectField'; @@ -50,13 +52,13 @@ interface BackendAssociationValues { } interface StorageTierCreateFormValues { - name: string; + metadata: { name: string }; description: string; backends: [BackendAssociationValues]; } const initialValues: StorageTierCreateFormValues = { - name: '', + metadata: { name: '' }, description: '', backends: [ { @@ -73,7 +75,7 @@ const initialValues: StorageTierCreateFormValues = { const StorageTierCreatePage = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const { mutate, error, isPending } = useCreateStorageTier(); + const { mutateAsync, error, isPending } = useCreateStorageTier(); const { data: backends = [], isLoading: backendsLoading } = usePrivateStorageBackends({ filter: STORAGE_BACKEND_READY_LIST_FILTER, }); @@ -84,7 +86,7 @@ const StorageTierCreatePage = () => { })); const schema = Yup.object({ - name: resourceNameSchema(t), + metadata: Yup.object({ name: resourceNameSchema(t) }), description: Yup.string(), backends: Yup.array() .of( @@ -124,11 +126,11 @@ const StorageTierCreatePage = () => { { - const backend = values.backends[0]; - mutate( - { - metadata: { name: values.name }, + onSubmit={async (values) => { + try { + const backend = values.backends[0]; + await mutateAsync({ + metadata: values.metadata, spec: { description: values.description, backends: [ @@ -142,16 +144,19 @@ const StorageTierCreatePage = () => { }, ], }, - }, - { onSuccess: () => navigate(TIERS_LIST_PATH) }, - ); + }); + navigate(TIERS_LIST_PATH); + } catch { + // Surfaced via the mutation's own `error` state below; nothing further to do here. + } }} > - {({ submitForm }) => ( + {({ submitForm, isSubmitting }) => ( + - + { @@ -230,7 +235,7 @@ const StorageTierCreatePage = () => { diff --git a/libs/ui-components/src/test-utils/createMockConnectTransport.ts b/libs/ui-components/src/test-utils/createMockConnectTransport.ts index d791e520..6fa541cf 100644 --- a/libs/ui-components/src/test-utils/createMockConnectTransport.ts +++ b/libs/ui-components/src/test-utils/createMockConnectTransport.ts @@ -179,7 +179,9 @@ export type MockTransportOverrides = { onStorageTierList?: ( req: StorageTiersListRequest, ) => MessageInitShape; - onStorageTierCreate?: (req: StorageTiersCreateRequest) => StorageTiersCreateResponse; + onStorageTierCreate?: ( + req: StorageTiersCreateRequest, + ) => StorageTiersCreateResponse | Promise; onStorageTierUpdate?: (req: StorageTiersUpdateRequest) => StorageTiersUpdateResponse; onStorageTierDelete?: ( req: StorageTiersDeleteRequest, From 285d2d3c9f67a09c3ebe6b633bbff55f8c1689a7 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Wed, 12 Aug 2026 12:14:02 +0300 Subject: [PATCH 6/7] =?UTF-8?q?OSAC-3600:=20Address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20isSubmitting=20alone=20gates=20submit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formik's isSubmitting already covers the full window mutateAsync is in flight (it only resets once onSubmit's returned promise settles), so the separate isPending check on the Create/Cancel buttons was redundant. Applied the same simplification to StorageTierCreatePage for consistency. Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- .../src/components/Storage/StorageTierCreatePage.tsx | 8 ++++---- .../src/pages/admin/StorageBackendCreatePage.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/libs/ui-components/src/components/Storage/StorageTierCreatePage.tsx b/libs/ui-components/src/components/Storage/StorageTierCreatePage.tsx index 85ff28c7..e4a68c65 100644 --- a/libs/ui-components/src/components/Storage/StorageTierCreatePage.tsx +++ b/libs/ui-components/src/components/Storage/StorageTierCreatePage.tsx @@ -75,7 +75,7 @@ const initialValues: StorageTierCreateFormValues = { const StorageTierCreatePage = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const { mutateAsync, error, isPending } = useCreateStorageTier(); + const { mutateAsync, error } = useCreateStorageTier(); const { data: backends = [], isLoading: backendsLoading } = usePrivateStorageBackends({ filter: STORAGE_BACKEND_READY_LIST_FILTER, }); @@ -225,8 +225,8 @@ const StorageTierCreatePage = () => { @@ -235,7 +235,7 @@ const StorageTierCreatePage = () => { diff --git a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx index b2487f97..27f1aaf1 100644 --- a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx +++ b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx @@ -62,7 +62,7 @@ const getStorageBackendSchema = (t: TFunction) => export const StorageBackendCreatePage = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const { mutateAsync, error, isPending } = useCreateStorageBackend(); + const { mutateAsync, error } = useCreateStorageBackend(); const providerOptions: SelectFieldOption[] = [ { value: 'vast', label: t('VAST') }, @@ -165,8 +165,8 @@ export const StorageBackendCreatePage = () => { @@ -175,7 +175,7 @@ export const StorageBackendCreatePage = () => { From 411749e3084231ba5a6cf8e9892f5e7e366f7436 Mon Sep 17 00:00:00 2001 From: Elay Aharoni Date: Wed, 12 Aug 2026 16:30:09 +0300 Subject: [PATCH 7/7] OSAC-3600: Make storage backend credentials optional via checkbox Credentials are not required by the backend, but the create form forced username and password. Add a 'Use credentials' checkbox (checked by default): when unchecked, the credential fields are hidden, their validation is skipped, and the credentials object is omitted from the create request entirely. When checked, both username and password remain required, so credentials are always all-or-nothing by construction. Addresses review feedback that the UI should reflect the backend's optional credentials. Assisted-by: Claude Code Signed-off-by: Elay Aharoni --- libs/i18n/locales/en/translation.json | 1 + .../admin/StorageBackendCreatePage.test.tsx | 30 ++++++++ .../pages/admin/StorageBackendCreatePage.tsx | 70 +++++++++++++------ 3 files changed, 81 insertions(+), 20 deletions(-) diff --git a/libs/i18n/locales/en/translation.json b/libs/i18n/locales/en/translation.json index b80d82cd..75ece9be 100644 --- a/libs/i18n/locales/en/translation.json +++ b/libs/i18n/locales/en/translation.json @@ -473,6 +473,7 @@ "Unspecified": "Unspecified", "Update": "Update", "Update role binding": "Update role binding", + "Use credentials": "Use credentials", "Use IPv4 CIDR notation (for example 10.128.0.0/14).": "Use IPv4 CIDR notation (for example 10.128.0.0/14).", "Use IPv4 CIDR notation (for example 172.30.0.0/16).": "Use IPv4 CIDR notation (for example 172.30.0.0/16).", "User data": "User data", diff --git a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx index 9541358c..3e6eed99 100644 --- a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx +++ b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.test.tsx @@ -57,6 +57,7 @@ describe('StorageBackendCreatePage', () => { expect(screen.getByLabelText(/^Provider/)).toBeInTheDocument(); expect(screen.getByRole('textbox', { name: 'Endpoint' })).toBeInTheDocument(); expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Use credentials' })).toBeChecked(); expect(screen.getByLabelText(/^Username/)).toBeInTheDocument(); expect(screen.getByLabelText(/^Password/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument(); @@ -188,6 +189,35 @@ describe('StorageBackendCreatePage', () => { expect(capturedRequest?.object?.spec?.credentials?.password).toBe(testBackendPassword); }, 15000); + it('hides credential fields and omits credentials from the payload when "Use credentials" is unchecked', async () => { + let capturedRequest: StorageBackendsCreateRequest | undefined; + const { user } = renderPage({ + onStorageBackendCreate: (req) => { + capturedRequest = req; + return create(StorageBackendsCreateResponseSchema, { + object: { id: 'new-backend-1', metadata: req.object?.metadata, spec: req.object?.spec }, + }); + }, + }); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), 'vast-prod-1'); + await user.click(screen.getByLabelText(/^Provider/)); + await user.click(screen.getByRole('option', { name: 'VAST' })); + await user.type(screen.getByRole('textbox', { name: 'Endpoint' }), 'vast.example.com:443'); + + await user.click(screen.getByRole('checkbox', { name: 'Use credentials' })); + + expect(screen.queryByLabelText(/^Username/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/^Password/)).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/admin/infrastructure/storage/backends'); + }); + expect(capturedRequest?.object?.spec?.credentials).toBeUndefined(); + }, 15000); + it('shows a form-level error and does not navigate when the name already exists', async () => { const { user } = renderPage({ onStorageBackendCreate: () => { diff --git a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx index 27f1aaf1..3db1be2c 100644 --- a/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx +++ b/libs/ui-components/src/pages/admin/StorageBackendCreatePage.tsx @@ -18,6 +18,7 @@ import * as Yup from 'yup'; import { useCreateStorageBackend } from '@osac/ui-components/api/v1/private/storage-backends'; import NameField from '@osac/ui-components/components/catalogProvision/wizard/fields/NameField'; +import { CheckboxField } from '@osac/ui-components/components/Form/CheckboxField'; import { InputField } from '@osac/ui-components/components/Form/InputField'; import LeaveFormConfirmation from '@osac/ui-components/components/Form/LeaveFormConfirmation'; import OsacForm from '@osac/ui-components/components/Form/OsacForm'; @@ -36,6 +37,7 @@ interface StorageBackendFormValues { provider: string; endpoint: string; description: string; + useCredentials: boolean; credentials: { username: string; password: string }; } @@ -44,6 +46,7 @@ const initialValues: StorageBackendFormValues = { provider: '', endpoint: '', description: '', + useCredentials: true, credentials: { username: '', password: '' }, }; @@ -53,9 +56,22 @@ const getStorageBackendSchema = (t: TFunction) => provider: Yup.string().required(t('Provider is required')), endpoint: Yup.string().required(t('Endpoint is required')), description: Yup.string(), - credentials: Yup.object({ - username: Yup.string().required(t('Username is required')), - password: Yup.string().required(t('Password is required')), + useCredentials: Yup.boolean(), + // Credentials are optional on the backend. When "Use credentials" is checked, + // both username and password are required; when unchecked they are omitted + // from the request entirely (see onSubmit), so there is no "only one set" state. + credentials: Yup.object().when('useCredentials', { + is: true, + then: (schema) => + schema.shape({ + username: Yup.string().required(t('Username is required')), + password: Yup.string().required(t('Password is required')), + }), + otherwise: (schema) => + schema.shape({ + username: Yup.string(), + password: Yup.string(), + }), }), }); @@ -99,10 +115,15 @@ export const StorageBackendCreatePage = () => { provider: values.provider, endpoint: values.endpoint, description: values.description, - credentials: { - username: values.credentials.username, - password: values.credentials.password, - }, + // Omit credentials entirely when the user opts out — the backend + // does not require them, and a partial credentials object is never + // sent (username/password are all-or-nothing via the checkbox). + ...(values.useCredentials && { + credentials: { + username: values.credentials.username, + password: values.credentials.password, + }, + }), }, }); navigate(BACKENDS_LIST_PATH); @@ -111,7 +132,7 @@ export const StorageBackendCreatePage = () => { } }} > - {({ submitForm, isSubmitting }) => ( + {({ submitForm, isSubmitting, values }) => ( @@ -135,19 +156,28 @@ export const StorageBackendCreatePage = () => { label={t('Description')} fieldId="storage-backend-description" /> - - + {values.useCredentials && ( + <> + + + + )}