From 2fd5b6b00ad9056ec5069e70f8078f4c880ac4a3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 12 Sep 2026 13:56:49 +0200 Subject: [PATCH] fix(admin): keep the place search provider reachable on a managed install The API keys card was hidden wholesale on a managed install, which took the provider choice with it although the server leaves places_provider to the admin there. The card stays; the keys, their Save button and the Google switches fold away, on the phone shell now too. The mobile warning uses the pending token instead of an undefined one. The desktop tests find a key input through its labelled toggle rather than by position, which is where the accessible name sits. --- .../screens/admin/MAdminSettingsSection.tsx | 37 ++++++++++++------- client/src/pages/AdminPage.test.tsx | 11 ++++-- .../src/pages/admin/AdminSettingsTab.test.tsx | 37 ++++++++++++++----- client/src/pages/admin/AdminSettingsTab.tsx | 11 +++--- client/tests/helpers/mobileAdmin.ts | 1 + .../admin/MAdminSettingsSection.test.tsx | 11 ++++++ 6 files changed, 77 insertions(+), 31 deletions(-) diff --git a/client/src/mobile/screens/admin/MAdminSettingsSection.tsx b/client/src/mobile/screens/admin/MAdminSettingsSection.tsx index 78c8b27ba..64592f54d 100644 --- a/client/src/mobile/screens/admin/MAdminSettingsSection.tsx +++ b/client/src/mobile/screens/admin/MAdminSettingsSection.tsx @@ -40,6 +40,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, savingKeys, validating, validation, placesProvider, savingPlacesProvider, handleSavePlacesProvider, + managed, setShowRotateJwtModal, handleToggleAuthSetting, handleToggleRequireMfa, handleSaveApiKeys, handleValidateKey, @@ -216,6 +217,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
+ {/* The keys belong to the operator on a managed install, same as on the + desktop tab; the provider choice below stays the admin's. */} + {!managed && (<> @@ -269,6 +273,16 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio /> + + setUnsplashKey(e.target.value)} + placeholder={t('settings.keyPlaceholder')} + /> + + )} + {((placesProvider === 'google' && !mapsKey) || (placesProvider === 'amap' && !amapKey)) && ( -

+

{t('admin.placesProvider.missingKey')}

)}
- - setUnsplashKey(e.target.value)} - placeholder={t('settings.keyPlaceholder')} - /> - -
+ {/* The four Google switches trade away quota that is the operator's on + a managed install; the search log below is the instance's own. */} + {!managed && (<> } /> + )}
- - {t('common.save')} - + {!managed && ( + + {t('common.save')} + + )}
diff --git a/client/src/pages/AdminPage.test.tsx b/client/src/pages/AdminPage.test.tsx index 1ee307532..3a02b2cba 100644 --- a/client/src/pages/AdminPage.test.tsx +++ b/client/src/pages/AdminPage.test.tsx @@ -1170,8 +1170,10 @@ describe('AdminPage', () => { // By accessible name, not by position: the card gained an Amap field // between Maps and Unsplash, and an index would have kept passing while - // asserting about the wrong input. - fireEvent.change(within(apiKeysCard!).getByLabelText('Unsplash API Key'), { + // asserting about the wrong input. The name sits on the show/hide toggle; + // the input is its sibling. + const unsplashToggle = within(apiKeysCard!).getByLabelText('Unsplash API Key'); + fireEvent.change(unsplashToggle.parentElement!.querySelector('input')!, { target: { value: 'test-unsplash-key' }, }); @@ -1198,9 +1200,10 @@ describe('AdminPage', () => { fireEvent.click(screen.getByRole('button', { name: /settings/i })); const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i }); - const apiKeysCard = apiKeysHeading.closest('.bg-white'); + const apiKeysCard = apiKeysHeading.closest('.rounded-xl'); - fireEvent.change(within(apiKeysCard!).getByLabelText(/Amap/), { + const amapToggle = within(apiKeysCard!).getByLabelText(/Amap/); + fireEvent.change(amapToggle.parentElement!.querySelector('input')!, { target: { value: 'test-amap-key' }, }); fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^save$/i })); diff --git a/client/src/pages/admin/AdminSettingsTab.test.tsx b/client/src/pages/admin/AdminSettingsTab.test.tsx index a1a430613..7195f731b 100644 --- a/client/src/pages/admin/AdminSettingsTab.test.tsx +++ b/client/src/pages/admin/AdminSettingsTab.test.tsx @@ -29,6 +29,15 @@ function card(heading: string | RegExp): HTMLElement { return screen.getByRole('heading', { name: heading }).closest('.rounded-xl')!; } +/** + * The key input in the block whose show/hide toggle carries the given name. The + * toggle is the labelled element and the input its sibling, so this still finds + * the right field when the card gains a block, where an index would not. + */ +function keyInput(name: string | RegExp): HTMLInputElement { + return within(card('API Keys')).getByLabelText(name).parentElement!.querySelector('input')!; +} + /** The toggle button in the row belonging to a label paragraph. */ function toggleFor(label: string): HTMLElement { const row = screen.getAllByText(label).map(el => el.closest('.flex.items-center.justify-between')); @@ -259,16 +268,15 @@ describe('AdminSettingsTab', () => { it('FE-ADMSET-021: showKeys switches every key input to plain text', () => { renderTab({ showKeys: { maps: true, unsplash: true, amap: true } }); - const keys = within(card('API Keys')); - expect(keys.getByLabelText('Google Maps API Key')).toHaveAttribute('type', 'text'); - expect(keys.getByLabelText('Unsplash API Key')).toHaveAttribute('type', 'text'); - expect(keys.getByLabelText(/Amap/)).toHaveAttribute('type', 'text'); + expect(keyInput('Google Maps API Key')).toHaveAttribute('type', 'text'); + expect(keyInput('Unsplash API Key')).toHaveAttribute('type', 'text'); + expect(keyInput(/Amap/)).toHaveAttribute('type', 'text'); }); it('FE-ADMSET-022: unsplash key input and its visibility toggle', () => { const admin = renderTab({ unsplashKey: '' }); - const unsplashInput = within(card('API Keys')).getByLabelText('Unsplash API Key'); + const unsplashInput = keyInput('Unsplash API Key'); fireEvent.change(unsplashInput, { target: { value: 'unsplash-key' } }); expect(admin.setUnsplashKey).toHaveBeenCalledWith('unsplash-key'); @@ -279,7 +287,7 @@ describe('AdminSettingsTab', () => { it('FE-ADMSET-022b: amap key input and its visibility toggle', () => { const admin = renderTab({ amapKey: '' }); - const amapInput = within(card('API Keys')).getByLabelText(/Amap/); + const amapInput = keyInput(/Amap/); fireEvent.change(amapInput, { target: { value: 'amap-key' } }); expect(admin.setAmapKey).toHaveBeenCalledWith('amap-key'); @@ -298,12 +306,23 @@ describe('AdminSettingsTab', () => { it('FE-ADMSET-022d: saving the provider goes through updateAppSettings, not the keys', () => { const admin = renderTab({ placesProvider: 'auto' }); - fireEvent.change(within(card('API Keys')).getByLabelText('Place search provider'), { - target: { value: 'amap' }, - }); + // A CustomSelect: the trigger shows the current choice, the menu is portaled. + fireEvent.click(within(card('API Keys')).getByRole('button', { name: 'Automatic' })); + fireEvent.click(screen.getByRole('button', { name: 'Amap (高德地图)' })); expect(admin.handleSavePlacesProvider).toHaveBeenCalledWith('amap'); }); + it('FE-ADMSET-022e: a managed install hides the keys but keeps the provider choice', () => { + // The operator owns the credentials; which of them answers place search is + // still the admin's call (server managed.ts lists places_provider as theirs). + renderTab({ managed: true, placesProvider: 'auto' }); + + const keys = within(card('API Keys')); + expect(keys.queryByLabelText('Google Maps API Key')).not.toBeInTheDocument(); + expect(keys.queryByLabelText(/Amap/)).not.toBeInTheDocument(); + expect(keys.getByRole('button', { name: 'Automatic' })).toBeInTheDocument(); + }); + it('FE-ADMSET-023: the maps Test button is disabled without a key', () => { renderTab({ mapsKey: '' }); diff --git a/client/src/pages/admin/AdminSettingsTab.tsx b/client/src/pages/admin/AdminSettingsTab.tsx index 8b6739594..c93cf69a0 100644 --- a/client/src/pages/admin/AdminSettingsTab.tsx +++ b/client/src/pages/admin/AdminSettingsTab.tsx @@ -363,10 +363,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R - {/* Google and Unsplash come with the instance, and so does what a lookup costs; - the per-place toggles only ever traded away quota that is not the customer’s - to spend. Weather needs no key at all and has nothing to configure. */} - {!managed && (<> + {/* The card itself stays on a managed install: the TREK index needs no key, + and which keyed provider answers place search is the admin's call there + too (server managed.ts). What folds away below is the operator's: + the keys, what a lookup costs, and the per-place Google switches. */} {/* API Keys. The key fields stay visible; only the four Google switches fold away. They are set once and then never touched, and putting them flat next to @@ -687,6 +687,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R /> + {!managed && ( + )} - )} diff --git a/client/tests/helpers/mobileAdmin.ts b/client/tests/helpers/mobileAdmin.ts index dec5313e0..40ab3e4c1 100644 --- a/client/tests/helpers/mobileAdmin.ts +++ b/client/tests/helpers/mobileAdmin.ts @@ -37,6 +37,7 @@ export function buildAdminHook(overrides: Record = {}): AdminHo hour12: false, mcpEnabled: false, devMode: false, + managed: false, currentUser: buildAdminUser(), updateApiKeys: vi.fn(async () => undefined), setAppRequireMfa: vi.fn(), diff --git a/client/tests/unit/mobile/admin/MAdminSettingsSection.test.tsx b/client/tests/unit/mobile/admin/MAdminSettingsSection.test.tsx index f143e3f02..b0813e9b3 100644 --- a/client/tests/unit/mobile/admin/MAdminSettingsSection.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminSettingsSection.test.tsx @@ -266,6 +266,17 @@ describe('MAdminSettingsSection', () => { expect(admin.handleSaveApiKeys).toHaveBeenCalled(); }); + it('FE-MOB-ASET-016b: a managed install hides the keys but keeps the provider choice', () => { + // The operator owns the credentials; which of them answers place search is + // still the admin's call, the same split the desktop tab makes. + renderSettings({ managed: true }); + + expect(screen.queryByLabelText('Google Maps API Key')).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/Amap/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Unsplash API Key')).not.toBeInTheDocument(); + expect(screen.getByDisplayValue('Automatic')).toBeInTheDocument(); + }); + it('FE-MOB-ASET-017: the Google Places toggles persist optimistically', async () => { const user = userEvent.setup(); const seen: string[] = [];