diff --git a/client/src/components/Admin/trekApiModel.test.ts b/client/src/components/Admin/trekApiModel.test.ts new file mode 100644 index 000000000..f0bafb434 --- /dev/null +++ b/client/src/components/Admin/trekApiModel.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { TREK_API_SOURCES, trekApiFacts, trekApiFields } from './trekApiModel'; + +// FE-TREKAPIMODEL-001 to FE-TREKAPIMODEL-003 + +const t = (key: string) => `<${key}>`; + +describe('trekApiModel', () => { + it('FE-TREKAPIMODEL-001: lists the ten result fields in their order, translated', () => { + expect(trekApiFields(t)).toEqual([ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ]); + }); + + it('FE-TREKAPIMODEL-002: leads with four facts, each with a glyph', () => { + const facts = trekApiFacts(t); + expect(facts.map((f) => f.text)).toEqual([ + '', + '', + '', + '', + ]); + for (const f of facts) expect(f.Icon).toBeTruthy(); + }); + + it('FE-TREKAPIMODEL-003: names the data sources untranslated', () => { + expect(TREK_API_SOURCES).toEqual(['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia']); + }); +}); diff --git a/client/src/components/Admin/trekApiModel.ts b/client/src/components/Admin/trekApiModel.ts new file mode 100644 index 000000000..35d15797d --- /dev/null +++ b/client/src/components/Admin/trekApiModel.ts @@ -0,0 +1,40 @@ +import { Globe2, KeyRound, ShieldOff, WifiOff } from 'lucide-react'; + +import type { TranslationFn } from '../../types'; + +/** + * Proper nouns, so they are not translated. Naming them is also a licence obligation, + * not decoration: ODbL and CC BY-SA both require attribution wherever their content is + * shown. + */ +export const TREK_API_SOURCES = ['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia']; + +/** + * The fields a TREK Places API result carries, for the desktop card and the phone block + * alike. Reuses the words TREK already has for these fields wherever it has them, so + * the chip row costs two new strings instead of ten. + */ +export function trekApiFields(t: TranslationFn): string[] { + return [ + t('places.formName'), + t('collections.coordinates'), + t('places.formCategory'), + t('places.formAddress'), + t('admin.trekApi.fieldPhone'), + t('common.email'), + t('places.formWebsite'), + t('places.formDescription'), + t('inspector.openingHours'), + t('admin.trekApi.fieldStableId'), + ]; +} + +/** The four facts the TREK Places API block leads with, each with its glyph. */ +export function trekApiFacts(t: TranslationFn) { + return [ + { Icon: Globe2, text: t('admin.trekApi.factPlaces') }, + { Icon: KeyRound, text: t('admin.trekApi.factNoKey') }, + { Icon: WifiOff, text: t('admin.trekApi.factOffline') }, + { Icon: ShieldOff, text: t('admin.trekApi.factPrivacy') }, + ]; +} diff --git a/client/src/components/Admin/useAdminSettingsActions.test.ts b/client/src/components/Admin/useAdminSettingsActions.test.ts new file mode 100644 index 000000000..acc2a74f2 --- /dev/null +++ b/client/src/components/Admin/useAdminSettingsActions.test.ts @@ -0,0 +1,143 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAdminSettingsActions } from './useAdminSettingsActions'; + +// FE-HOOK-ADMSETACT-001 to FE-HOOK-ADMSETACT-008 + +const api = vi.hoisted(() => ({ + updateOidc: vi.fn(), + updateAppSettings: vi.fn(), + updatePlacesPhotos: vi.fn(), + updatePlacesAutocomplete: vi.fn(), + updatePlacesDetails: vi.fn(), + updatePlacesEnrich: vi.fn(), + updatePlaceShadow: vi.fn(), +})); +vi.mock('../../api/client', () => ({ + adminApi: { + updateOidc: api.updateOidc, + updatePlacesPhotos: api.updatePlacesPhotos, + updatePlacesAutocomplete: api.updatePlacesAutocomplete, + updatePlacesDetails: api.updatePlacesDetails, + updatePlacesEnrich: api.updatePlacesEnrich, + updatePlaceShadow: api.updatePlaceShadow, + }, + authApi: { updateAppSettings: api.updateAppSettings }, +})); + +const t = (key: string) => key; + +function host(over: Record = {}) { + return { + toast: { success: vi.fn(), error: vi.fn() }, + oidcConfig: { + issuer: 'https://idp', + client_id: 'trek', + client_secret: '', + display_name: 'SSO', + discovery_url: '', + }, + setSavingOidc: vi.fn(), + allowedFileTypes: 'jpg,pdf', + setSavingFileTypes: vi.fn(), + setPlacesPhotosEnabledState: vi.fn(), + setPlacesPhotosEnabled: vi.fn(), + setPlacesAutocompleteEnabledState: vi.fn(), + setPlacesAutocompleteEnabled: vi.fn(), + setPlacesDetailsEnabledState: vi.fn(), + setPlacesDetailsEnabled: vi.fn(), + setPlacesEnrichEnabledState: vi.fn(), + setPlacesEnrichEnabled: vi.fn(), + setPlaceShadowEnabledState: vi.fn(), + setPlaceShadowEnabled: vi.fn(), + ...over, + }; +} + +function mount(admin = host()) { + return { admin, actions: renderHook(() => useAdminSettingsActions(admin, t)).result.current }; +} + +beforeEach(() => { + for (const fn of Object.values(api)) fn.mockReset().mockResolvedValue({}); +}); + +describe('useAdminSettingsActions', () => { + it('FE-HOOK-ADMSETACT-001: saving OIDC leaves an empty secret out and toasts', async () => { + const { admin, actions } = mount(); + await act(() => actions.saveOidc()); + expect(api.updateOidc).toHaveBeenCalledWith({ + issuer: 'https://idp', + client_id: 'trek', + display_name: 'SSO', + discovery_url: '', + }); + expect(admin.toast.success).toHaveBeenCalledWith('admin.oidcSaved'); + expect(admin.setSavingOidc.mock.calls).toEqual([[true], [false]]); + }); + + it('FE-HOOK-ADMSETACT-002: a typed secret is sent along', async () => { + const admin = host({ + oidcConfig: { issuer: 'i', client_id: 'c', client_secret: 's3cret', display_name: 'd', discovery_url: 'u' }, + }); + const { actions } = mount(admin); + await act(() => actions.saveOidc()); + expect(api.updateOidc).toHaveBeenCalledWith(expect.objectContaining({ client_secret: 's3cret' })); + }); + + it('FE-HOOK-ADMSETACT-003: a failed OIDC save shows the server message and clears saving', async () => { + api.updateOidc.mockRejectedValue({ response: { data: { error: 'Bad issuer' } } }); + const { admin, actions } = mount(); + await act(() => actions.saveOidc()); + expect(admin.toast.error).toHaveBeenCalledWith('Bad issuer'); + expect(admin.setSavingOidc).toHaveBeenLastCalledWith(false); + }); + + it('FE-HOOK-ADMSETACT-004: saving file types writes the list and toasts', async () => { + const { admin, actions } = mount(); + await act(() => actions.saveFileTypes()); + expect(api.updateAppSettings).toHaveBeenCalledWith({ allowed_file_types: 'jpg,pdf' }); + expect(admin.toast.success).toHaveBeenCalledWith('admin.fileTypesSaved'); + expect(admin.setSavingFileTypes.mock.calls).toEqual([[true], [false]]); + }); + + it('FE-HOOK-ADMSETACT-005: a failed file type save shows the generic error', async () => { + api.updateAppSettings.mockRejectedValue(new Error('x')); + const { admin, actions } = mount(); + await act(() => actions.saveFileTypes()); + expect(admin.toast.error).toHaveBeenCalledWith('common.error'); + expect(admin.toast.success).not.toHaveBeenCalled(); + expect(admin.setSavingFileTypes).toHaveBeenLastCalledWith(false); + }); + + it('FE-HOOK-ADMSETACT-006: a place switch sets the tab and the store, then saves', async () => { + const { admin, actions } = mount(); + await act(() => actions.togglePlacesPhotos(true)); + expect(admin.setPlacesPhotosEnabledState).toHaveBeenCalledWith(true); + expect(admin.setPlacesPhotosEnabled).toHaveBeenCalledWith(true); + expect(api.updatePlacesPhotos).toHaveBeenCalledWith(true); + }); + + it('FE-HOOK-ADMSETACT-007: a failed place switch flips both back without a toast', async () => { + api.updatePlaceShadow.mockRejectedValue(new Error('x')); + const { admin, actions } = mount(); + await act(() => actions.togglePlaceShadow(true)); + expect(admin.setPlaceShadowEnabledState.mock.calls).toEqual([[true], [false]]); + expect(admin.setPlaceShadowEnabled.mock.calls).toEqual([[true], [false]]); + expect(admin.toast.error).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-ADMSETACT-008: each place switch writes its own setting', async () => { + const { admin, actions } = mount(); + await act(() => actions.togglePlacesAutocomplete(false)); + await act(() => actions.togglePlacesDetails(false)); + await act(() => actions.togglePlacesEnrich(false)); + expect(api.updatePlacesAutocomplete).toHaveBeenCalledWith(false); + expect(api.updatePlacesDetails).toHaveBeenCalledWith(false); + expect(api.updatePlacesEnrich).toHaveBeenCalledWith(false); + expect(admin.setPlacesAutocompleteEnabled).toHaveBeenCalledWith(false); + expect(admin.setPlacesDetailsEnabledState).toHaveBeenCalledWith(false); + expect(admin.setPlacesEnrichEnabled).toHaveBeenCalledWith(false); + }); +}); diff --git a/client/src/components/Admin/useAdminSettingsActions.ts b/client/src/components/Admin/useAdminSettingsActions.ts new file mode 100644 index 000000000..15259b474 --- /dev/null +++ b/client/src/components/Admin/useAdminSettingsActions.ts @@ -0,0 +1,110 @@ +import { adminApi, authApi } from '../../api/client'; +import type { TranslationFn } from '../../types'; +import { getApiErrorMessage } from '../../types'; + +type Flag = (value: boolean) => void; + +interface OidcFields { + issuer: string; + client_id: string; + client_secret: string; + display_name: string; + discovery_url: string; +} + +interface SettingsActionsHost { + toast: { success: (message: string) => void; error: (message: string) => void }; + oidcConfig: OidcFields; + setSavingOidc: Flag; + allowedFileTypes: string; + setSavingFileTypes: Flag; + setPlacesPhotosEnabledState: Flag; + setPlacesPhotosEnabled: Flag; + setPlacesAutocompleteEnabledState: Flag; + setPlacesAutocompleteEnabled: Flag; + setPlacesDetailsEnabledState: Flag; + setPlacesDetailsEnabled: Flag; + setPlacesEnrichEnabledState: Flag; + setPlacesEnrichEnabled: Flag; + setPlaceShadowEnabledState: Flag; + setPlaceShadowEnabled: Flag; +} + +/** + * The saves behind the admin Settings tab and its phone twin: the OIDC provider, the + * allowed file types and the place switches. The values live in the admin hook; this + * only writes them, so both shells hand in the same admin bag. + */ +export function useAdminSettingsActions(admin: SettingsActionsHost, t: TranslationFn) { + const { toast, oidcConfig, setSavingOidc, allowedFileTypes, setSavingFileTypes } = admin; + + const saveOidc = async () => { + setSavingOidc(true); + try { + const payload: Record = { + issuer: oidcConfig.issuer, + client_id: oidcConfig.client_id, + display_name: oidcConfig.display_name, + discovery_url: oidcConfig.discovery_url, + }; + if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret; + await adminApi.updateOidc(payload); + toast.success(t('admin.oidcSaved')); + } catch (err: unknown) { + toast.error(getApiErrorMessage(err, t('common.error'))); + } finally { + setSavingOidc(false); + } + }; + + const saveFileTypes = async () => { + setSavingFileTypes(true); + try { + await authApi.updateAppSettings({ allowed_file_types: allowedFileTypes }); + toast.success(t('admin.fileTypesSaved')); + } catch { + toast.error(t('common.error')); + } finally { + setSavingFileTypes(false); + } + }; + + /** + * A place switch shows the new value at once, in the tab and in the store the rest of + * the app reads, and both flip back quietly when the save fails. + */ + const placeSwitch = (setLocal: Flag, setStore: Flag, save: (enabled: boolean) => Promise) => { + return async (next: boolean) => { + setLocal(next); + setStore(next); + try { + await save(next); + } catch { + setLocal(!next); + setStore(!next); + } + }; + }; + + return { + saveOidc, + saveFileTypes, + togglePlacesPhotos: placeSwitch(admin.setPlacesPhotosEnabledState, admin.setPlacesPhotosEnabled, (enabled) => + adminApi.updatePlacesPhotos(enabled) + ), + togglePlacesAutocomplete: placeSwitch( + admin.setPlacesAutocompleteEnabledState, + admin.setPlacesAutocompleteEnabled, + (enabled) => adminApi.updatePlacesAutocomplete(enabled) + ), + togglePlacesDetails: placeSwitch(admin.setPlacesDetailsEnabledState, admin.setPlacesDetailsEnabled, (enabled) => + adminApi.updatePlacesDetails(enabled) + ), + togglePlacesEnrich: placeSwitch(admin.setPlacesEnrichEnabledState, admin.setPlacesEnrichEnabled, (enabled) => + adminApi.updatePlacesEnrich(enabled) + ), + togglePlaceShadow: placeSwitch(admin.setPlaceShadowEnabledState, admin.setPlaceShadowEnabled, (enabled) => + adminApi.updatePlaceShadow(enabled) + ), + }; +} diff --git a/client/src/mobile/screens/admin/MAdminSettingsSection.tsx b/client/src/mobile/screens/admin/MAdminSettingsSection.tsx index 4953cd76c..114cb2e4d 100644 --- a/client/src/mobile/screens/admin/MAdminSettingsSection.tsx +++ b/client/src/mobile/screens/admin/MAdminSettingsSection.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import { AlertTriangle, ChevronDown, RefreshCw } from 'lucide-react' -import { adminApi, authApi } from '../../../api/client' -import { getApiErrorMessage } from '../../../types' +import { useAdminSettingsActions } from '../../../components/Admin/useAdminSettingsActions' import { placesGoogleOnlyHint } from '../../../utils/placeSource' import type { TranslationFn } from '../../../types' import type { useAdmin } from '../../../pages/admin/useAdmin' @@ -29,21 +28,16 @@ interface MAdminSettingsSectionProps { export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectionProps) { const [providerPickerOpen, setProviderPickerOpen] = useState(false) const { - toast, - setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled, - placesPhotosEnabled, setPlacesPhotosEnabledState, - placesAutocompleteEnabled, setPlacesAutocompleteEnabledState, - placesDetailsEnabled, setPlacesDetailsEnabledState, - placesEnrichEnabled, setPlacesEnrichEnabledState, + placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled, placesGoogleOnly, handleTogglePlacesGoogleOnly, - placeShadowEnabled, setPlaceShadowEnabledState, - oidcConfig, setOidcConfig, savingOidc, setSavingOidc, + placeShadowEnabled, + oidcConfig, setOidcConfig, savingOidc, passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration, oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration, envOverrideOidcOnly, oidcConfigured, requireMfa, passkeyLogin, setPasskeyLogin, passkeyConfigured, webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn, - allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, + allowedFileTypes, setAllowedFileTypes, savingFileTypes, mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, hasMapsKey, hasAmapKey, keyInputProps, mapsKeyTestable, savingKeys, validating, validation, placesProvider, savingPlacesProvider, handleSavePlacesProvider, managed, @@ -52,36 +46,10 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio handleSaveApiKeys, handleValidateKey, } = admin - const saveFileTypes = async () => { - setSavingFileTypes(true) - try { - await authApi.updateAppSettings({ allowed_file_types: allowedFileTypes }) - toast.success(t('admin.fileTypesSaved')) - } catch { - toast.error(t('common.error')) - } finally { - setSavingFileTypes(false) - } - } - - const saveOidc = async () => { - setSavingOidc(true) - try { - const payload: Record = { - issuer: oidcConfig.issuer, - client_id: oidcConfig.client_id, - display_name: oidcConfig.display_name, - discovery_url: oidcConfig.discovery_url, - } - if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret - await adminApi.updateOidc(payload) - toast.success(t('admin.oidcSaved')) - } catch (err: unknown) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setSavingOidc(false) - } - } + const { + saveOidc, saveFileTypes, togglePlacesPhotos, togglePlacesAutocomplete, togglePlacesDetails, togglePlacesEnrich, + togglePlaceShadow, + } = useAdminSettingsActions(admin, t) return (
@@ -279,16 +247,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio { - setPlacesPhotosEnabledState(next) - setPlacesPhotosEnabled(next) - try { - await adminApi.updatePlacesPhotos(next) - } catch { - setPlacesPhotosEnabledState(!next) - setPlacesPhotosEnabled(!next) - } - }} + onChange={togglePlacesPhotos} /> } /> @@ -299,16 +258,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio { - setPlacesAutocompleteEnabledState(next) - setPlacesAutocompleteEnabled(next) - try { - await adminApi.updatePlacesAutocomplete(next) - } catch { - setPlacesAutocompleteEnabledState(!next) - setPlacesAutocompleteEnabled(!next) - } - }} + onChange={togglePlacesAutocomplete} /> } /> @@ -319,16 +269,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio { - setPlacesDetailsEnabledState(next) - setPlacesDetailsEnabled(next) - try { - await adminApi.updatePlacesDetails(next) - } catch { - setPlacesDetailsEnabledState(!next) - setPlacesDetailsEnabled(!next) - } - }} + onChange={togglePlacesDetails} /> } /> @@ -339,16 +280,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio { - setPlacesEnrichEnabledState(next) - setPlacesEnrichEnabled(next) - try { - await adminApi.updatePlacesEnrich(next) - } catch { - setPlacesEnrichEnabledState(!next) - setPlacesEnrichEnabled(!next) - } - }} + onChange={togglePlacesEnrich} /> } /> @@ -434,16 +366,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio { - setPlaceShadowEnabledState(next) - setPlaceShadowEnabled(next) - try { - await adminApi.updatePlaceShadow(next) - } catch { - setPlaceShadowEnabledState(!next) - setPlaceShadowEnabled(!next) - } - }} + onChange={togglePlaceShadow} /> } /> diff --git a/client/src/mobile/screens/admin/MApiProviderBlocks.tsx b/client/src/mobile/screens/admin/MApiProviderBlocks.tsx index f29f77dbf..1adfdb8a0 100644 --- a/client/src/mobile/screens/admin/MApiProviderBlocks.tsx +++ b/client/src/mobile/screens/admin/MApiProviderBlocks.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' -import { Check, ChevronRight, Globe2, KeyRound, Library, ShieldOff, WifiOff, X } from 'lucide-react' +import { Check, ChevronRight, Library, X } from 'lucide-react' import TrekMark from '../../../components/shared/TrekMark' +import { TREK_API_SOURCES as SOURCES, trekApiFacts, trekApiFields } from '../../../components/Admin/trekApiModel' import { useTranslation } from '../../../i18n' /** @@ -19,13 +20,6 @@ import { useTranslation } from '../../../i18n' * wording, key for key, so the two pages cannot drift in what they claim. */ -/** - * Proper nouns, so they are not translated. Naming them is also a licence - * obligation rather than decoration: ODbL and CC BY-SA both require attribution - * wherever their content is shown. - */ -const SOURCES = ['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia'] - const chip = 'rounded-full border border-[color:var(--m-rowbr)] px-2 py-[2px] font-geist text-[0.625rem]' /** One group of chips inside the disclosure: what you get, what you do not, where it comes from. */ @@ -65,26 +59,9 @@ export function MTrekApiBlock() { const { t } = useTranslation() const [open, setOpen] = useState(false) - const facts = [ - { Icon: Globe2, text: t('admin.trekApi.factPlaces') }, - { Icon: KeyRound, text: t('admin.trekApi.factNoKey') }, - { Icon: WifiOff, text: t('admin.trekApi.factOffline') }, - { Icon: ShieldOff, text: t('admin.trekApi.factPrivacy') }, - ] - + const facts = trekApiFacts(t) // The same fields the desktop card lists, reusing words TREK already has. - const fields = [ - t('places.formName'), - t('collections.coordinates'), - t('places.formCategory'), - t('places.formAddress'), - t('admin.trekApi.fieldPhone'), - t('common.email'), - t('places.formWebsite'), - t('places.formDescription'), - t('inspector.openingHours'), - t('admin.trekApi.fieldStableId'), - ] + const fields = trekApiFields(t) return (
diff --git a/client/src/pages/admin/AdminSettingsTab.tsx b/client/src/pages/admin/AdminSettingsTab.tsx index 914f265d6..9232d8d88 100644 --- a/client/src/pages/admin/AdminSettingsTab.tsx +++ b/client/src/pages/admin/AdminSettingsTab.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { adminApi, authApi } from '../../api/client' +import { adminApi } from '../../api/client' import { getApiErrorMessage } from '../../types' import { AlertTriangle, CheckCircle, Eye, EyeOff, FileType, Fingerprint, KeyRound, Loader2, LockKeyhole, LogIn, @@ -16,6 +16,7 @@ import GoogleOptions from './GoogleOptions' import GoogleDailyLimitRow from '../../components/Admin/GoogleDailyLimitRow' import ProviderBlock from './ProviderBlock' import TrekApiCard from './TrekApiCard' +import { useAdminSettingsActions } from '../../components/Admin/useAdminSettingsActions' import { placesGoogleOnlyHint } from '../../utils/placeSource' import type { TranslationFn } from '../../types' import type { useAdmin } from './useAdmin' @@ -112,22 +113,18 @@ function SaveBar({ onClick, saving, label }: { onClick: () => void; saving: bool export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): React.ReactElement { const { toast, - setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled, - placesPhotosEnabled, setPlacesPhotosEnabledState, - placesAutocompleteEnabled, setPlacesAutocompleteEnabledState, - placesDetailsEnabled, setPlacesDetailsEnabledState, - placesEnrichEnabled, setPlacesEnrichEnabledState, + placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled, placesGoogleOnly, handleTogglePlacesGoogleOnly, transitProvider, setTransitProviderState, transitGoogleKeySource, setTransitGoogleKeySource, - placeShadowEnabled, setPlaceShadowEnabledState, - oidcConfig, setOidcConfig, savingOidc, setSavingOidc, + placeShadowEnabled, + oidcConfig, setOidcConfig, savingOidc, passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration, oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration, envOverrideOidcOnly, oidcConfigured, requireMfa, passkeyLogin, setPasskeyLogin, passkeyConfigured, webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn, - allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, + allowedFileTypes, setAllowedFileTypes, savingFileTypes, mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, hasMapsKey, hasAmapKey, keyInputProps, mapsKeyTestable, showKeys, savingKeys, validating, validation, placesProvider, savingPlacesProvider, handleSavePlacesProvider, managed, @@ -135,6 +132,10 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R handleToggleAuthSetting, handleToggleRequireMfa, toggleKey, handleSaveApiKeys, handleValidateKey, } = admin + const { + saveOidc, saveFileTypes, togglePlacesPhotos, togglePlacesAutocomplete, togglePlacesDetails, togglePlacesEnrich, + togglePlaceShadow, + } = useAdminSettingsActions(admin, t) const passwordLoginLocked = !passwordLogin && !oidcLogin const oidcLoginLocked = !passwordLogin && oidcLogin @@ -345,19 +346,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R { - setSavingOidc(true) - try { - const payload: Record = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, discovery_url: oidcConfig.discovery_url } - if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret - await adminApi.updateOidc(payload) - toast.success(t('admin.oidcSaved')) - } catch (err: unknown) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setSavingOidc(false) - } - }} + onClick={saveOidc} /> )} @@ -380,14 +369,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R { - setSavingFileTypes(true) - try { - await authApi.updateAppSettings({ allowed_file_types: allowedFileTypes }) - toast.success(t('admin.fileTypesSaved')) - } catch { toast.error(t('common.error')) } - finally { setSavingFileTypes(false) } - }} + onClick={saveFileTypes} /> @@ -466,12 +448,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R { - const next = !placesPhotosEnabled - setPlacesPhotosEnabledState(next) - setPlacesPhotosEnabled(next) - try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) } - }} + onToggle={() => togglePlacesPhotos(!placesPhotosEnabled)} /> } /> @@ -482,12 +459,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R { - const next = !placesAutocompleteEnabled - setPlacesAutocompleteEnabledState(next) - setPlacesAutocompleteEnabled(next) - try { await adminApi.updatePlacesAutocomplete(next) } catch { setPlacesAutocompleteEnabledState(!next); setPlacesAutocompleteEnabled(!next) } - }} + onToggle={() => togglePlacesAutocomplete(!placesAutocompleteEnabled)} /> } /> @@ -498,12 +470,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R { - const next = !placesDetailsEnabled - setPlacesDetailsEnabledState(next) - setPlacesDetailsEnabled(next) - try { await adminApi.updatePlacesDetails(next) } catch { setPlacesDetailsEnabledState(!next); setPlacesDetailsEnabled(!next) } - }} + onToggle={() => togglePlacesDetails(!placesDetailsEnabled)} /> } /> @@ -514,12 +481,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R { - const next = !placesEnrichEnabled - setPlacesEnrichEnabledState(next) - setPlacesEnrichEnabled(next) - try { await adminApi.updatePlacesEnrich(next) } catch { setPlacesEnrichEnabledState(!next); setPlacesEnrichEnabled(!next) } - }} + onToggle={() => togglePlacesEnrich(!placesEnrichEnabled)} /> } /> @@ -667,12 +629,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R { - const next = !placeShadowEnabled - setPlaceShadowEnabledState(next) - setPlaceShadowEnabled(next) - try { await adminApi.updatePlaceShadow(next) } catch { setPlaceShadowEnabledState(!next); setPlaceShadowEnabled(!next) } - }} + onToggle={() => togglePlaceShadow(!placeShadowEnabled)} /> } /> diff --git a/client/src/pages/admin/TrekApiCard.tsx b/client/src/pages/admin/TrekApiCard.tsx index 31a2f4c33..35669e4f2 100644 --- a/client/src/pages/admin/TrekApiCard.tsx +++ b/client/src/pages/admin/TrekApiCard.tsx @@ -1,9 +1,10 @@ import React from 'react' -import { Check, ChevronRight, Globe2, KeyRound, Library, ShieldOff, WifiOff, X } from 'lucide-react' +import { Check, ChevronRight, Library, X } from 'lucide-react' import TrekMark from '../../components/shared/TrekMark' import { fs } from '../../components/shared/DialogShell' import { StatusPill } from '../../components/Settings/settingsKit' import type { TranslationFn } from '../../types' +import { TREK_API_SOURCES as SOURCES, trekApiFacts, trekApiFields } from '../../components/Admin/trekApiModel' interface TrekApiCardProps { t: TranslationFn @@ -21,13 +22,6 @@ interface TrekApiCardProps { * is the row count of the current index, and "no queries logged" is enforced in * three places on the server rather than promised here. */ -/** - * Proper nouns, so they are not translated. Naming them is also a licence - * obligation, not decoration: ODbL and CC BY-SA both require attribution - * wherever their content is shown. - */ -const SOURCES = ['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia'] - const CHIP = 'rounded-full border px-2.5 py-[3px] font-medium' /** One of the three lists under "more": an icon and a name, chips, a note. */ @@ -45,27 +39,8 @@ function FactGroup({ icon, title, note, children }: { icon: React.ReactNode; tit } export default function TrekApiCard({ t }: TrekApiCardProps): React.ReactElement { - // Reuses the words TREK already has for these fields wherever it has them, - // so the chip row costs two new strings instead of ten. - const fields = [ - t('places.formName'), - t('collections.coordinates'), - t('places.formCategory'), - t('places.formAddress'), - t('admin.trekApi.fieldPhone'), - t('common.email'), - t('places.formWebsite'), - t('places.formDescription'), - t('inspector.openingHours'), - t('admin.trekApi.fieldStableId'), - ] - - const facts = [ - { Icon: Globe2, text: t('admin.trekApi.factPlaces') }, - { Icon: KeyRound, text: t('admin.trekApi.factNoKey') }, - { Icon: WifiOff, text: t('admin.trekApi.factOffline') }, - { Icon: ShieldOff, text: t('admin.trekApi.factPrivacy') }, - ] + const fields = trekApiFields(t) + const facts = trekApiFacts(t) return (