mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
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:
@@ -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>
|
||||
</>)}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user