From feb99ce398a4b80fdf609aceb19a579a5e1d68a1 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:57:37 +0200 Subject: [PATCH] refactor(admin): share the admin settings saves and the TREK API lists between desktop and phone useAdminSettingsActions now holds the OIDC and file type saves and the five place switches, writing through the admin bag both shells already receive. trekApiModel keeps the TREK Places API fields, facts and sources the desktop card and the phone block list. --- .../src/components/Admin/trekApiModel.test.ts | 39 +++++ client/src/components/Admin/trekApiModel.ts | 40 +++++ .../Admin/useAdminSettingsActions.test.ts | 143 ++++++++++++++++++ .../Admin/useAdminSettingsActions.ts | 110 ++++++++++++++ .../screens/admin/MAdminSettingsSection.tsx | 105 ++----------- .../screens/admin/MApiProviderBlocks.tsx | 31 +--- client/src/pages/admin/AdminSettingsTab.tsx | 77 +++------- client/src/pages/admin/TrekApiCard.tsx | 33 +--- 8 files changed, 371 insertions(+), 207 deletions(-) create mode 100644 client/src/components/Admin/trekApiModel.test.ts create mode 100644 client/src/components/Admin/trekApiModel.ts create mode 100644 client/src/components/Admin/useAdminSettingsActions.test.ts create mode 100644 client/src/components/Admin/useAdminSettingsActions.ts 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 (