fix(admin): read key presence from the app config, not the form

The place search provider block now renders on a managed install too, and
there the key fields are empty by design: the operator owns the credentials
and user-profile never sends them. An operator key set through the
environment is invisible in the fields either way. The warning behind the
provider select read those fields, so it told every managed admin that
search "falls back to OpenStreetMap" while their key was answering.

app-config already carries the answer as has_maps_key, and it resolves the
same chain the search itself uses, environment included. has_amap_key sits
beside it, so both come into the store at boot and both follow a save.
This commit is contained in:
Maurice
2026-09-12 17:25:16 +02:00
parent 3e8ab22836
commit aa0d62f063
8 changed files with 64 additions and 19 deletions
+3 -2
View File
@@ -284,7 +284,7 @@ function RouteFallback() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
const { loadAddons } = useAddonStore()
const { loadPlugins } = usePluginStore()
@@ -301,13 +301,14 @@ export default function App() {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
setManaged(!!config?.managed)
setDemoMode(!!config?.demo_mode)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
if (config?.version) setAppVersion(config.version)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
@@ -38,7 +38,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, savingKeys, validating, validation,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, hasMapsKey, hasAmapKey, savingKeys, validating, validation,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
managed,
setShowRotateJwtModal,
@@ -295,7 +295,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
<option value="amap">{t('admin.placesProvider.amap')}</option>
<option value="openstreetmap">{t('admin.placesProvider.openstreetmap')}</option>
</select>
{((placesProvider === 'google' && !mapsKey) || (placesProvider === 'amap' && !amapKey)) && (
{/* From app-config, like the desktop card: the key fields are empty
on a managed install and on an operator key set by environment. */}
{((placesProvider === 'google' && !hasMapsKey) || (placesProvider === 'amap' && !hasAmapKey)) && (
<p className="mt-1 font-geist text-[0.625rem] font-bold text-[color:var(--m-st-pending)]">
{t('admin.placesProvider.missingKey')}
</p>
@@ -298,7 +298,7 @@ describe('AdminSettingsTab', () => {
it('FE-ADMSET-022c: warns when the selected provider has no key behind it', () => {
// A provider chosen without a key answers with OpenStreetMap rather than
// failing, which is quiet enough to be mistaken for the provider working.
renderTab({ placesProvider: 'amap', amapKey: '' });
renderTab({ placesProvider: 'amap', hasAmapKey: false });
expect(
within(card('API Keys')).getByText(/falls back to OpenStreetMap/i),
).toBeInTheDocument();
@@ -323,6 +323,16 @@ describe('AdminSettingsTab', () => {
expect(keys.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
});
it('FE-ADMSET-022f: an operator key keeps the managed install quiet', () => {
// The key fields are not on the page here, and an operator key set through
// the environment would not show up in them anyway. app-config knows.
renderTab({ managed: true, placesProvider: 'google', mapsKey: '', hasMapsKey: true });
expect(
within(card('API Keys')).queryByText(/falls back to OpenStreetMap/i),
).not.toBeInTheDocument();
});
it('FE-ADMSET-023: the maps Test button is disabled without a key', () => {
renderTab({ mapsKey: '' });
+16 -13
View File
@@ -35,7 +35,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, showKeys, savingKeys, validating, validation,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, hasMapsKey, hasAmapKey, showKeys, savingKeys, validating, validation,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
managed,
setShowRotateJwtModal,
@@ -363,17 +363,16 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</div>
</div>
{/* 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
the keys made a rarely-used option look as important as the key it
depends on. Weather is a quiet row for the same reason in reverse: it
had the loudest treatment on the card and is the one thing here with
nothing to configure. */}
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 is the operator's: the keys,
what a lookup costs, and the per-place Google switches. Those switches
sit under the keys rather than flat beside them because they are set
once and then never touched, and side by side a rarely-used option
looked as important as the key it depends on. Weather is a quiet row
for the same reason in reverse: it had the loudest treatment on the
card and is the one thing here with nothing to configure. */}
<div className="bg-surface-card rounded-xl border border-edge overflow-hidden">
<div className="px-6 py-4 border-b border-edge-secondary">
<h2 className="font-semibold text-content">{t('admin.apiKeys')}</h2>
@@ -656,8 +655,12 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
/>
{/* A provider chosen without a key behind it answers with
OpenStreetMap rather than failing, which is quiet enough to be
mistaken for the provider working. Say so. */}
{((placesProvider === 'google' && !mapsKey) || (placesProvider === 'amap' && !amapKey)) && (
mistaken for the provider working. Say so. Asked of app-config
and not of the fields above: those are empty on a managed
install and on an operator key that came from the environment,
and saying "falls back to OpenStreetMap" to an admin whose
search works is worse than saying nothing. */}
{((placesProvider === 'google' && !hasMapsKey) || (placesProvider === 'amap' && !hasAmapKey)) && (
<p className="flex items-start gap-2 text-xs text-warning bg-warning-soft border border-warning/30 rounded-lg px-3 py-2 mt-2">
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
{t('admin.placesProvider.missingKey')}
+6 -1
View File
@@ -48,6 +48,11 @@ export function useAdmin() {
const mcpEnabled = useAddonStore(s => s.isEnabled('mcp'))
const devMode = useAuthStore(s => s.devMode)
const managed = useAuthStore(s => s.managed)
// Whether a key is actually behind a provider is app-config's answer, not the
// form's: on a managed install the fields below stay empty by design, and an
// operator key set through the environment never reaches them either.
const hasMapsKey = useAuthStore(s => s.hasMapsKey)
const hasAmapKey = useAuthStore(s => s.hasAmapKey)
// ?tab= makes a section linkable: a support reply, an onboarding mail or a
// bookmark can point at the one panel it is about instead of at the top of a
@@ -491,7 +496,7 @@ export function useAdmin() {
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
smtpValues, setSmtpValues, smtpLoaded,
mapsKey, setMapsKey, weatherKey, setWeatherKey, unsplashKey, setUnsplashKey,
amapKey, setAmapKey,
amapKey, setAmapKey, hasMapsKey, hasAmapKey,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
showKeys, setShowKeys, savingKeys, validating, validation,
updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal,
+10
View File
@@ -47,6 +47,10 @@ interface AuthState {
isPrerelease: boolean
appVersion: string
hasMapsKey: boolean
/** The same question for Amap. Kept apart from hasMapsKey rather than folded
* into one "has a search key": which of the two is missing decides what the
* admin has to go and do. */
hasAmapKey: boolean
serverTimezone: string
/** Server policy: all users must enable MFA */
appRequireMfa: boolean
@@ -75,6 +79,7 @@ interface AuthState {
setIsPrerelease: (val: boolean) => void
setAppVersion: (val: string) => void
setHasMapsKey: (val: boolean) => void
setHasAmapKey: (val: boolean) => void
setServerTimezone: (tz: string) => void
setAppRequireMfa: (val: boolean) => void
setTripRemindersEnabled: (val: boolean) => void
@@ -125,6 +130,7 @@ export const useAuthStore = create<AuthState>()(
isPrerelease: false,
appVersion: '',
hasMapsKey: false,
hasAmapKey: false,
serverTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
appRequireMfa: false,
tripRemindersEnabled: false,
@@ -343,6 +349,9 @@ export const useAuthStore = create<AuthState>()(
if ('maps_api_key' in keys) {
set({ hasMapsKey: !!keys.maps_api_key })
}
if ('amap_api_key' in keys) {
set({ hasAmapKey: !!keys.amap_api_key })
}
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error saving API keys'))
}
@@ -386,6 +395,7 @@ export const useAuthStore = create<AuthState>()(
setIsPrerelease: (val: boolean) => set({ isPrerelease: val }),
setAppVersion: (val: string) => set({ appVersion: val }),
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
setHasAmapKey: (val: boolean) => set({ hasAmapKey: val }),
setServerTimezone: (tz: string) => set({ serverTimezone: tz }),
setAppRequireMfa: (val: boolean) => set({ appRequireMfa: val }),
setTripRemindersEnabled: (val: boolean) => set({ tripRemindersEnabled: val }),
+2
View File
@@ -146,6 +146,8 @@ export function buildAdminHook(overrides: Record<string, unknown> = {}): AdminHo
setUnsplashKey: vi.fn(),
amapKey: '',
setAmapKey: vi.fn(),
hasMapsKey: false,
hasAmapKey: false,
placesProvider: 'auto',
savingPlacesProvider: false,
showKeys: {},
@@ -277,6 +277,18 @@ describe('MAdminSettingsSection', () => {
expect(screen.getByDisplayValue('Automatic')).toBeInTheDocument();
});
it('FE-MOB-ASET-016c: the missing-key notice follows app-config, not the fields', () => {
// A provider chosen without a key answers with OpenStreetMap rather than
// failing. Which of the two is true is app-config's answer: the key fields
// are empty on a managed install and on a key set by the environment.
const { unmount } = render(<Harness admin={buildAdminHook({ placesProvider: 'amap', hasAmapKey: false })} />);
expect(screen.getByText(/falls back to OpenStreetMap/i)).toBeInTheDocument();
unmount();
renderSettings({ managed: true, placesProvider: 'google', mapsKey: '', hasMapsKey: true });
expect(screen.queryByText(/falls back to OpenStreetMap/i)).not.toBeInTheDocument();
});
it('FE-MOB-ASET-017: the Google Places toggles persist optimistically', async () => {
const user = userEvent.setup();
const seen: string[] = [];