feat(settings): set the Google Maps key through PLACES_API_KEY

The server already put PLACES_API_KEY ahead of the key saved in the admin
panel, but nothing said so: the variable was in no template and the admin
field kept showing a stored value that no search used.

GET /me/settings now answers env_keys, the variable behind each key the
environment sets (Google, Unsplash, Amap), and null for that key's value.
The admin field turns read-only and names the variable, Test still probes
the key a search resolves to, and a save leaves those names out so the
stored fallback survives. The variable is documented in .env.example,
docker-compose.yml, the Helm chart (as a secret) and the wiki.

Addresses discussion #1881.
This commit is contained in:
Maurice
2026-09-24 16:17:54 +02:00
parent 79cd3095e0
commit 8906b66bc8
45 changed files with 308 additions and 36 deletions
@@ -44,7 +44,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, hasMapsKey, hasAmapKey, savingKeys, validating, validation,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, hasMapsKey, hasAmapKey, keyInputProps, mapsKeyTestable, savingKeys, validating, validation,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
managed,
setShowRotateJwtModal,
@@ -245,12 +245,12 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
aria-label={t('admin.mapsKey')}
value={mapsKey}
onChange={(e) => setMapsKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
{...keyInputProps('maps')}
/>
</div>
<MAdminButton
variant="ghost"
disabled={!mapsKey}
disabled={!mapsKeyTestable}
busy={!!validating.maps}
onClick={() => handleValidateKey('maps')}
className="h-[42px]"
@@ -374,7 +374,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
aria-label={t('admin.amapKey')}
value={amapKey}
onChange={(e) => setAmapKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
{...keyInputProps('amap')}
/>
</MProviderBlock>
@@ -384,7 +384,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
aria-label={t('admin.unsplashKey')}
value={unsplashKey}
onChange={(e) => setUnsplashKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
{...keyInputProps('unsplash')}
/>
</MProviderBlock>
</>)}
+1 -1
View File
@@ -75,7 +75,7 @@ export function MAdminInput({ className = '', ...props }: InputHTMLAttributes<HT
return (
<input
{...props}
className={`h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)] ${className}`}
className={`h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)] disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
/>
)
}
@@ -1,4 +1,4 @@
// FE-ADMSET-001 to FE-ADMSET-042
// FE-ADMSET-001 to FE-ADMSET-043
import { http, HttpResponse } from 'msw';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -339,6 +339,21 @@ describe('AdminSettingsTab', () => {
expect(within(card('API Keys')).getByRole('button', { name: /^test$/i })).toBeDisabled();
});
it('FE-ADMSET-043: a key the environment sets is read-only and names its variable (#1881)', () => {
const admin = renderTab({
keyInputProps: (field: string) =>
field === 'maps' ? { disabled: true, placeholder: 'Set via PLACES_API_KEY' } : { disabled: false, placeholder: 'Enter key...' },
mapsKeyTestable: true,
});
expect(keyInput('Google Maps API Key')).toBeDisabled();
expect(keyInput('Google Maps API Key')).toHaveAttribute('placeholder', 'Set via PLACES_API_KEY');
expect(keyInput(/unsplash/i)).toBeEnabled();
// The empty field still tests: the probe uses the key a search resolves to.
fireEvent.click(within(card('API Keys')).getByRole('button', { name: /^test$/i }));
expect(admin.handleValidateKey).toHaveBeenCalledWith('maps');
});
it('FE-ADMSET-024: the maps Test button validates the maps key', () => {
const admin = renderTab({ mapsKey: 'AIza-test' });
+8 -8
View File
@@ -37,7 +37,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, hasMapsKey, hasAmapKey, showKeys, savingKeys, validating, validation,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, hasMapsKey, hasAmapKey, keyInputProps, mapsKeyTestable, showKeys, savingKeys, validating, validation,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
managed,
setShowRotateJwtModal,
@@ -401,8 +401,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
type={showKeys.maps ? 'text' : 'password'}
value={mapsKey}
onChange={e => setMapsKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent"
{...keyInputProps('maps')}
className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed"
/>
<button
type="button"
@@ -415,7 +415,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</div>
<button type="button"
onClick={() => handleValidateKey('maps')}
disabled={!mapsKey || validating.maps}
disabled={!mapsKeyTestable || validating.maps}
className="px-3 py-2 text-sm border border-edge rounded-lg hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5 text-content-secondary"
>
{validating.maps ? (
@@ -549,8 +549,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
type={showKeys.unsplash ? 'text' : 'password'}
value={unsplashKey}
onChange={e => setUnsplashKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent"
{...keyInputProps('unsplash')}
className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed"
/>
<button
type="button"
@@ -575,8 +575,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
type={showKeys.amap ? 'text' : 'password'}
value={amapKey}
onChange={e => setAmapKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent"
{...keyInputProps('amap')}
className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed"
/>
<button
type="button"
+45 -1
View File
@@ -1,4 +1,4 @@
// FE-ADMHOOK-001 to FE-ADMHOOK-051
// FE-ADMHOOK-001 to FE-ADMHOOK-053
import { http, HttpResponse } from 'msw';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -378,6 +378,50 @@ describe('useAdmin', () => {
expect(result.current.savingKeys).toBe(false);
});
it('FE-ADMHOOK-052: a key set by the environment locks its field and stays testable (#1881)', async () => {
server.use(
http.get('/api/auth/me/settings', () =>
HttpResponse.json({
settings: { maps_api_key: null, unsplash_api_key: 'unsplash-k', env_keys: { maps_api_key: 'PLACES_API_KEY' } },
})
)
);
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.keyInputProps('maps').disabled).toBe(true));
expect(result.current.keyInputProps('maps').placeholder).toBe('Set via PLACES_API_KEY');
expect(result.current.keyInputProps('unsplash')).toEqual({ disabled: false, placeholder: 'Enter key...' });
// Test probes what a search resolves to, so the empty field does not block it.
expect(result.current.mapsKey).toBe('');
expect(result.current.mapsKeyTestable).toBe(true);
});
it('FE-ADMHOOK-053: saving leaves a key the environment sets out of the body', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.get('/api/auth/me/settings', () =>
HttpResponse.json({ settings: { maps_api_key: null, env_keys: { maps_api_key: 'PLACES_API_KEY' } } })
),
http.put('/api/auth/me/api-keys', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
})
);
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.mapsKeyTestable).toBe(true));
act(() => result.current.setUnsplashKey('u'));
await act(async () => {
await result.current.handleSaveApiKeys();
});
// Sending the empty field would clear the stored value the install falls
// back to once the variable is removed.
expect(body).not.toHaveProperty('maps_api_key');
expect(body).toMatchObject({ unsplash_api_key: 'u' });
});
it('FE-ADMHOOK-020: handleSaveApiKeys surfaces the thrown error message', async () => {
server.use(http.put('/api/auth/me/api-keys', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
const { result } = await mountAdmin();
+27 -9
View File
@@ -179,6 +179,8 @@ export function useAdmin() {
const [weatherKey, setWeatherKey] = useState<string>('')
const [unsplashKey, setUnsplashKey] = useState<string>('')
const [amapKey, setAmapKey] = useState<string>('')
// Keys the operator set in an environment variable: key name → variable (#1881).
const [envKeys, setEnvKeys] = useState<Record<string, string>>({})
/**
* Which provider answers place search. Not a key, so it saves through
* updateAppSettings rather than with the keys — and it is saved on change
@@ -269,6 +271,7 @@ export function useAdmin() {
setWeatherKey(data.settings?.openweather_api_key || '')
setUnsplashKey(data.settings?.unsplash_api_key || '')
setAmapKey(data.settings?.amap_api_key || '')
setEnvKeys(data.settings?.env_keys ?? {})
} catch (err: unknown) {
// ignore
}
@@ -317,15 +320,30 @@ export function useAdmin() {
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
}
// A key the environment sets leaves its field empty, and saving that '' would
// clear the stored value the install falls back to once the variable is gone.
// So those names stay out of every save.
const apiKeysBody = () => Object.fromEntries(
Object.entries({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey, amap_api_key: amapKey })
.filter(([name]) => !envKeys[name]),
)
/** A key field's lock: read-only, naming the variable, while the environment sets that key. */
const keyInputProps = (field: 'maps' | 'unsplash' | 'amap') => {
const variable = envKeys[`${field}_api_key`]
return {
disabled: !!variable,
placeholder: variable ? t('admin.keyFromEnv', { name: variable }) : t('settings.keyPlaceholder'),
}
}
// Test probes the key a search resolves to, so one from the environment is
// testable with the field left empty.
const mapsKeyTestable = !!(mapsKey || envKeys.maps_api_key)
const handleSaveApiKeys = async () => {
setSavingKeys(true)
try {
await updateApiKeys({
maps_api_key: mapsKey,
openweather_api_key: weatherKey,
unsplash_api_key: unsplashKey,
amap_api_key: amapKey,
})
await updateApiKeys(apiKeysBody())
toast.success(t('admin.keySaved'))
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Unknown error')
@@ -338,7 +356,7 @@ export function useAdmin() {
setValidating({ maps: true, weather: true })
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey, amap_api_key: amapKey })
await updateApiKeys(apiKeysBody())
const result = await authApi.validateKeys()
setValidation(result)
} catch (err: unknown) {
@@ -352,7 +370,7 @@ export function useAdmin() {
setValidating(prev => ({ ...prev, [keyType]: true }))
try {
// Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey, amap_api_key: amapKey })
await updateApiKeys(apiKeysBody())
const result = await authApi.validateKeys()
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
} catch (err: unknown) {
@@ -517,7 +535,7 @@ export function useAdmin() {
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
smtpValues, setSmtpValues, smtpLoaded,
mapsKey, setMapsKey, weatherKey, setWeatherKey, unsplashKey, setUnsplashKey,
amapKey, setAmapKey, hasMapsKey, hasAmapKey,
amapKey, setAmapKey, hasMapsKey, hasAmapKey, keyInputProps, mapsKeyTestable,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
showKeys, setShowKeys, savingKeys, validating, validation,
updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal,