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.
This commit is contained in:
Maurice
2026-10-09 10:57:37 +02:00
parent 8c8069e983
commit feb99ce398
8 changed files with 371 additions and 207 deletions
@@ -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([
'<places.formName>',
'<collections.coordinates>',
'<places.formCategory>',
'<places.formAddress>',
'<admin.trekApi.fieldPhone>',
'<common.email>',
'<places.formWebsite>',
'<places.formDescription>',
'<inspector.openingHours>',
'<admin.trekApi.fieldStableId>',
]);
});
it('FE-TREKAPIMODEL-002: leads with four facts, each with a glyph', () => {
const facts = trekApiFacts(t);
expect(facts.map((f) => f.text)).toEqual([
'<admin.trekApi.factPlaces>',
'<admin.trekApi.factNoKey>',
'<admin.trekApi.factOffline>',
'<admin.trekApi.factPrivacy>',
]);
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']);
});
});
@@ -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') },
];
}
@@ -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<string, unknown> = {}) {
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);
});
});
@@ -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<string, unknown> = {
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<unknown>) => {
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)
),
};
}
@@ -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<string, unknown> = {
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 (
<div className="space-y-3">
@@ -279,16 +247,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
<MToggle
checked={placesPhotosEnabled}
ariaLabel={t('admin.placesPhotos.title')}
onChange={async (next) => {
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
<MToggle
checked={placesAutocompleteEnabled}
ariaLabel={t('admin.placesAutocomplete.title')}
onChange={async (next) => {
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
<MToggle
checked={placesDetailsEnabled}
ariaLabel={t('admin.placesDetails.title')}
onChange={async (next) => {
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
<MToggle
checked={placesEnrichEnabled}
ariaLabel={t('admin.placesEnrich.title')}
onChange={async (next) => {
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
<MToggle
checked={placeShadowEnabled}
ariaLabel={t('admin.placeShadow.title')}
onChange={async (next) => {
setPlaceShadowEnabledState(next)
setPlaceShadowEnabled(next)
try {
await adminApi.updatePlaceShadow(next)
} catch {
setPlaceShadowEnabledState(!next)
setPlaceShadowEnabled(!next)
}
}}
onChange={togglePlaceShadow}
/>
}
/>
@@ -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 (
<div className="relative mt-[18px]">
+17 -60
View File
@@ -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
<SaveBar
label={t('common.save')}
saving={savingOidc}
onClick={async () => {
setSavingOidc(true)
try {
const payload: Record<string, unknown> = { 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}
/>
</SettingsCard>
)}
@@ -380,14 +369,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
<SaveBar
label={t('common.save')}
saving={savingFileTypes}
onClick={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) }
}}
onClick={saveFileTypes}
/>
</SettingsCard>
@@ -466,12 +448,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
<ToggleSwitch
on={placesPhotosEnabled}
label={t('admin.placesPhotos.title')}
onToggle={async () => {
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
<ToggleSwitch
on={placesAutocompleteEnabled}
label={t('admin.placesAutocomplete.title')}
onToggle={async () => {
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
<ToggleSwitch
on={placesDetailsEnabled}
label={t('admin.placesDetails.title')}
onToggle={async () => {
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
<ToggleSwitch
on={placesEnrichEnabled}
label={t('admin.placesEnrich.title')}
onToggle={async () => {
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
<ToggleSwitch
on={placeShadowEnabled}
label={t('admin.placeShadow.title')}
onToggle={async () => {
const next = !placeShadowEnabled
setPlaceShadowEnabledState(next)
setPlaceShadowEnabled(next)
try { await adminApi.updatePlaceShadow(next) } catch { setPlaceShadowEnabledState(!next); setPlaceShadowEnabled(!next) }
}}
onToggle={() => togglePlaceShadow(!placeShadowEnabled)}
/>
}
/>
+4 -29
View File
@@ -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 (
<div className="overflow-hidden rounded-[14px] border bg-surface-card shadow-sm"