mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
feat(map): amap as map api provider and display template
This commit is contained in:
@@ -1168,9 +1168,12 @@ describe('AdminPage', () => {
|
||||
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
|
||||
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.rounded-xl');
|
||||
|
||||
// The Unsplash key is the second 'Enter key...' input (after Maps).
|
||||
const keyInputs = within(apiKeysCard!).getAllByPlaceholderText('Enter key...');
|
||||
fireEvent.change(keyInputs[1], { target: { value: 'test-unsplash-key' } });
|
||||
// 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'), {
|
||||
target: { value: 'test-unsplash-key' },
|
||||
});
|
||||
|
||||
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^save$/i }));
|
||||
|
||||
@@ -1178,6 +1181,34 @@ describe('AdminPage', () => {
|
||||
expect(capturedBody?.unsplash_api_key).toBe('test-unsplash-key');
|
||||
});
|
||||
});
|
||||
|
||||
it('typing in the Amap API key and clicking Save sends amap_api_key', async () => {
|
||||
let capturedBody: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
http.put('/api/auth/me/api-keys', async ({ request }) => {
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ success: true });
|
||||
})
|
||||
);
|
||||
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
render(<AdminPage />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', { name: /settings/i }));
|
||||
|
||||
const apiKeysHeading = await screen.findByRole('heading', { name: /^api keys$/i });
|
||||
const apiKeysCard = apiKeysHeading.closest<HTMLElement>('.bg-white');
|
||||
|
||||
fireEvent.change(within(apiKeysCard!).getByLabelText(/Amap/), {
|
||||
target: { value: 'test-amap-key' },
|
||||
});
|
||||
fireEvent.click(within(apiKeysCard!).getByRole('button', { name: /^save$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody?.amap_api_key).toBe('test-amap-key');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-ADMIN-044: Validate API key in Settings tab', () => {
|
||||
|
||||
@@ -253,18 +253,22 @@ describe('AdminSettingsTab', () => {
|
||||
expect(admin.toggleKey).toHaveBeenCalledWith('maps');
|
||||
});
|
||||
|
||||
it('FE-ADMSET-021: showKeys switches both key inputs to plain text', () => {
|
||||
renderTab({ showKeys: { maps: true, unsplash: true } });
|
||||
// Selected by accessible name rather than by position: the API-keys card has
|
||||
// grown a field before now (Amap), and an index-based lookup silently starts
|
||||
// asserting about a different input when it does.
|
||||
it('FE-ADMSET-021: showKeys switches every key input to plain text', () => {
|
||||
renderTab({ showKeys: { maps: true, unsplash: true, amap: true } });
|
||||
|
||||
const inputs = within(card('API Keys')).getAllByPlaceholderText('Enter key...');
|
||||
expect(inputs[0]).toHaveAttribute('type', 'text');
|
||||
expect(inputs[1]).toHaveAttribute('type', 'text');
|
||||
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');
|
||||
});
|
||||
|
||||
it('FE-ADMSET-022: unsplash key input and its visibility toggle', () => {
|
||||
const admin = renderTab({ unsplashKey: '' });
|
||||
|
||||
const unsplashInput = within(card('API Keys')).getAllByPlaceholderText('Enter key...')[1];
|
||||
const unsplashInput = within(card('API Keys')).getByLabelText('Unsplash API Key');
|
||||
fireEvent.change(unsplashInput, { target: { value: 'unsplash-key' } });
|
||||
expect(admin.setUnsplashKey).toHaveBeenCalledWith('unsplash-key');
|
||||
|
||||
@@ -272,6 +276,34 @@ describe('AdminSettingsTab', () => {
|
||||
expect(admin.toggleKey).toHaveBeenCalledWith('unsplash');
|
||||
});
|
||||
|
||||
it('FE-ADMSET-022b: amap key input and its visibility toggle', () => {
|
||||
const admin = renderTab({ amapKey: '' });
|
||||
|
||||
const amapInput = within(card('API Keys')).getByLabelText(/Amap/);
|
||||
fireEvent.change(amapInput, { target: { value: 'amap-key' } });
|
||||
expect(admin.setAmapKey).toHaveBeenCalledWith('amap-key');
|
||||
|
||||
fireEvent.click(amapInput.parentElement!.querySelector('button')!);
|
||||
expect(admin.toggleKey).toHaveBeenCalledWith('amap');
|
||||
});
|
||||
|
||||
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: '' });
|
||||
expect(
|
||||
within(card('API Keys')).getByText(/falls back to OpenStreetMap/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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' },
|
||||
});
|
||||
expect(admin.handleSavePlacesProvider).toHaveBeenCalledWith('amap');
|
||||
});
|
||||
|
||||
it('FE-ADMSET-023: the maps Test button is disabled without a key', () => {
|
||||
renderTab({ mapsKey: '' });
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ 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, showKeys, savingKeys, validating, validation,
|
||||
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, showKeys, savingKeys, validating, validation,
|
||||
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
|
||||
managed,
|
||||
setShowRotateJwtModal,
|
||||
handleToggleAuthSetting, handleToggleRequireMfa,
|
||||
@@ -546,6 +547,32 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
<p className="text-xs text-content-faint mt-1.5">{t('admin.unsplashKeyHint')}</p>
|
||||
</div>
|
||||
</ProviderBlock>
|
||||
|
||||
{/* Amap Key. No Test button: /auth/validate-keys only knows how to
|
||||
probe Google Places and OpenWeatherMap, and a button that silently
|
||||
tests something else is worse than no button. */}
|
||||
<ProviderBlock title={t('admin.amapKey')}>
|
||||
<div>
|
||||
<div className="relative">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleKey('amap')}
|
||||
aria-label={t('admin.amapKey')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-content-faint hover:text-content"
|
||||
>
|
||||
{showKeys.amap ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-content-faint mt-1.5">{t('admin.amapKeyHint')}</p>
|
||||
</div>
|
||||
</ProviderBlock>
|
||||
{/* Transit Backend (#1699) — Transitous has no GTFS for much of Asia,
|
||||
so an install can point transit search at Google instead, on the
|
||||
same key. Falls back to Transitous when no key resolves. A block of
|
||||
@@ -605,6 +632,40 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
</ProviderBlock>
|
||||
</>)}
|
||||
|
||||
{/* Which keyed provider answers place search. Outside the managed guard
|
||||
on purpose: the operator owns the credentials, but what their users
|
||||
search against is still the admin's call (see server managed.ts). */}
|
||||
<ProviderBlock title={t('admin.placesProvider.title')}>
|
||||
<div>
|
||||
<p className="text-xs text-content-faint">{t('admin.placesProvider.subtitle')}</p>
|
||||
<CustomSelect
|
||||
value={placesProvider}
|
||||
disabled={savingPlacesProvider}
|
||||
onChange={value => {
|
||||
if (value === placesProvider) return
|
||||
void handleSavePlacesProvider(String(value))
|
||||
}}
|
||||
options={[
|
||||
{ value: 'auto', label: t('admin.placesProvider.auto') },
|
||||
{ value: 'google', label: t('admin.placesProvider.google') },
|
||||
{ value: 'amap', label: t('admin.placesProvider.amap') },
|
||||
{ value: 'openstreetmap', label: t('admin.placesProvider.openstreetmap') },
|
||||
]}
|
||||
size="sm"
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
{/* 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)) && (
|
||||
<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')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ProviderBlock>
|
||||
|
||||
{/* The search log sits with the index rather than with a provider:
|
||||
it records which result somebody picked, so a candidate index can
|
||||
be judged against real searches later. Off unless switched on,
|
||||
|
||||
@@ -168,6 +168,15 @@ export function useAdmin() {
|
||||
const [mapsKey, setMapsKey] = useState<string>('')
|
||||
const [weatherKey, setWeatherKey] = useState<string>('')
|
||||
const [unsplashKey, setUnsplashKey] = useState<string>('')
|
||||
const [amapKey, setAmapKey] = useState<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
|
||||
* rather than with the Save button, because it is one choice from a list and
|
||||
* the effect is immediate everywhere.
|
||||
*/
|
||||
const [placesProvider, setPlacesProvider] = useState<string>('auto')
|
||||
const [savingPlacesProvider, setSavingPlacesProvider] = useState<boolean>(false)
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
|
||||
const [savingKeys, setSavingKeys] = useState<boolean>(false)
|
||||
const [validating, setValidating] = useState<Record<string, boolean>>({})
|
||||
@@ -234,6 +243,7 @@ export function useAdmin() {
|
||||
setPasskeyLogin(!!config.passkey_login)
|
||||
setPasskeyConfigured(!!config.passkey_configured)
|
||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||
if (config.places_provider) setPlacesProvider(config.places_provider)
|
||||
} catch (err: unknown) {
|
||||
// ignore
|
||||
}
|
||||
@@ -245,6 +255,7 @@ export function useAdmin() {
|
||||
setMapsKey(data.settings?.maps_api_key || '')
|
||||
setWeatherKey(data.settings?.openweather_api_key || '')
|
||||
setUnsplashKey(data.settings?.unsplash_api_key || '')
|
||||
setAmapKey(data.settings?.amap_api_key || '')
|
||||
} catch (err: unknown) {
|
||||
// ignore
|
||||
}
|
||||
@@ -300,6 +311,7 @@ export function useAdmin() {
|
||||
maps_api_key: mapsKey,
|
||||
openweather_api_key: weatherKey,
|
||||
unsplash_api_key: unsplashKey,
|
||||
amap_api_key: amapKey,
|
||||
})
|
||||
toast.success(t('admin.keySaved'))
|
||||
} catch (err: unknown) {
|
||||
@@ -313,7 +325,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 })
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey, amap_api_key: amapKey })
|
||||
const result = await authApi.validateKeys()
|
||||
setValidation(result)
|
||||
} catch (err: unknown) {
|
||||
@@ -327,7 +339,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 })
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey, amap_api_key: amapKey })
|
||||
const result = await authApi.validateKeys()
|
||||
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
|
||||
} catch (err: unknown) {
|
||||
@@ -337,6 +349,21 @@ export function useAdmin() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSavePlacesProvider = async (value: string) => {
|
||||
const previous = placesProvider
|
||||
setPlacesProvider(value)
|
||||
setSavingPlacesProvider(true)
|
||||
try {
|
||||
await authApi.updateAppSettings({ places_provider: value })
|
||||
toast.success(t('admin.placesProvider.saved'))
|
||||
} catch (err: unknown) {
|
||||
setPlacesProvider(previous)
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setSavingPlacesProvider(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!createForm.username.trim() || !createForm.email.trim() || !createForm.password.trim()) {
|
||||
toast.error(t('admin.toast.fieldsRequired'))
|
||||
@@ -464,6 +491,8 @@ export function useAdmin() {
|
||||
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
|
||||
smtpValues, setSmtpValues, smtpLoaded,
|
||||
mapsKey, setMapsKey, weatherKey, setWeatherKey, unsplashKey, setUnsplashKey,
|
||||
amapKey, setAmapKey,
|
||||
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
|
||||
showKeys, setShowKeys, savingKeys, validating, validation,
|
||||
updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal,
|
||||
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
|
||||
|
||||
Reference in New Issue
Block a user