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[] = [];