feat(admin): the phone's API-keys card, rebuilt like the desktop tab

The desktop tab was reshaped around one idea: a key should be the exception
rather than the thing you are expected to paste. The phone still had the older
shape, where Google wore the "Recommended" chip and the TREK Places API was not
on the screen at all. Two pages, opposite advice, same setting.

So the phone gets the same three comparable blocks in the same order: the TREK
index first, with its own ribbon and a folded panel of what it does and does not
carry, then the two keys under theirs. The Google caveat is said before the
field rather than after it, because somebody about to paste a key should read it
while deciding. The four Google switches moved under the key they spend, folded
away: they are set once and never touched again, and open they were longer than
the rest of the card together. Open-Meteo left the card entirely, as it did on
the desktop, since it takes no key and had the loudest treatment of anything
there.

The provider choice is TREK's own picker now. A native select is drawn by the
operating system and was the one control on the page that did not belong to the
app.

Its description was also describing a TREK that no longer exists: it said one
service answers place search. The index and OpenStreetMap answer every search
and the setting only decides who is asked once they come up empty
(maps.service.ts, keyedProvider). Both that line and the missing-key notice say
so now, in all 23 languages, and the two key hints have short forms for the
narrow screen.
This commit is contained in:
Maurice
2026-09-13 21:07:55 +02:00
parent c2751d9c80
commit 76f45512d1
28 changed files with 448 additions and 159 deletions
@@ -1,9 +1,12 @@
import { AlertTriangle, RefreshCw, Sun } from 'lucide-react'
import { useState } from 'react'
import { AlertTriangle, ChevronDown, RefreshCw } from 'lucide-react'
import { adminApi, authApi } from '../../../api/client'
import { getApiErrorMessage } from '../../../types'
import type { TranslationFn } from '../../../types'
import type { useAdmin } from '../../../pages/admin/useAdmin'
import MToggle from '../../components/MToggle'
import { MBlockDisclosure, MProviderBlock, MTrekApiBlock } from './MApiProviderBlocks'
import MSetPickerSheet from '../settings/MSetPickerSheet'
import {
MAdminButton,
MAdminCard,
@@ -23,6 +26,7 @@ interface MAdminSettingsSectionProps {
// types, API keys, OIDC and the danger zone — the full desktop settings tab
// relaid as mobile cards. All state and mutations come from useAdmin.
export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectionProps) {
const [providerPickerOpen, setProviderPickerOpen] = useState(false)
const {
toast,
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled,
@@ -217,20 +221,22 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
<MAdminCard>
<MAdminCardHead title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')} />
<div className="space-y-3">
{/* Comparable blocks in the order we would have somebody choose them,
matching the desktop tab: the free source first, the keys after it.
The recommendation used to sit on the Google field here, which told
a phone admin the opposite of what the same setting says on a
laptop. */}
<MTrekApiBlock />
{/* The keys belong to the operator on a managed install, same as on the
desktop tab; the provider choice below stays the admin's. */}
{!managed && (<>
<MAdminField
label={
<span className="flex items-center gap-2">
{t('admin.mapsKey')}
<span className="rounded-full bg-[color:color-mix(in_srgb,var(--m-st-confirmed)_14%,transparent)] px-[7px] py-[2px] font-geist text-[0.5625rem] font-bold text-[color:var(--m-st-confirmed)]">
{t('admin.recommended')}
</span>
</span>
}
hint={t('admin.mapsKeyHintLong')}
>
<MProviderBlock title={t('admin.mapsKey')} badge={t('admin.googleCaveat.badge')} tone="caution">
{/* Said before the field, not after it: somebody about to paste a key
should read this while deciding, not once they already have. */}
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">
{t('admin.mapsKeyHintShort')}
</p>
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<MAdminSecretInput
@@ -251,63 +257,19 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
</MAdminButton>
</div>
{validation.maps === true && (
<p className="mt-1 font-geist text-[0.625rem] font-bold text-[color:var(--m-st-confirmed)]">
<p className="font-geist text-[0.625rem] font-bold text-[color:var(--m-st-confirmed)]">
{t('admin.keyValid')}
</p>
)}
{validation.maps === false && (
<p className="mt-1 font-geist text-[0.625rem] font-bold text-[color:var(--m-st-danger)]">
<p className="font-geist text-[0.625rem] font-bold text-[color:var(--m-st-danger)]">
{t('admin.keyInvalid')}
</p>
)}
</MAdminField>
{/* No Test button, same as the desktop tab: /auth/validate-keys only
knows how to probe Google Places and OpenWeatherMap. */}
<MAdminField label={t('admin.amapKey')} hint={t('admin.amapKeyHint')}>
<MAdminSecretInput
aria-label={t('admin.amapKey')}
value={amapKey}
onChange={(e) => setAmapKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
/>
</MAdminField>
<MAdminField label={t('admin.unsplashKey')} hint={t('admin.unsplashKeyHint')}>
<MAdminSecretInput
aria-label={t('admin.unsplashKey')}
value={unsplashKey}
onChange={(e) => setUnsplashKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
/>
</MAdminField>
</>)}
<MAdminField label={t('admin.placesProvider.title')} hint={t('admin.placesProvider.subtitle')}>
<select
value={placesProvider}
disabled={savingPlacesProvider}
onChange={(e) => handleSavePlacesProvider(e.target.value)}
className="h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none disabled:opacity-50"
>
<option value="auto">{t('admin.placesProvider.auto')}</option>
<option value="google">{t('admin.placesProvider.google')}</option>
<option value="amap">{t('admin.placesProvider.amap')}</option>
<option value="openstreetmap">{t('admin.placesProvider.openstreetmap')}</option>
</select>
{/* From app-config, like the desktop card: the key fields are empty
on a managed install and on an operator key set by environment. */}
{((placesProvider === 'google' && !hasMapsKey) || (placesProvider === 'amap' && !hasAmapKey)) && (
<p className="mt-1 font-geist text-[0.625rem] font-bold text-[color:var(--m-st-pending)]">
{t('admin.placesProvider.missingKey')}
</p>
)}
</MAdminField>
<div>
{/* The four Google switches trade away quota that is the operator's on
a managed install; the search log below is the instance's own. */}
{!managed && (<>
{/* What the key is allowed to be spent on. Under the key rather than
loose at the foot of the card: every one of them is a Google
request, and none of them mean anything without it. */}
<MBlockDisclosure label={t('admin.placesUsageTitle')}>
<MAdminRow
title={t('admin.placesPhotos.title')}
hint={t('admin.placesPhotos.subtitle')}
@@ -388,8 +350,71 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
/>
}
/>
</>)}
</MBlockDisclosure>
</MProviderBlock>
{/* No Test button, same as the desktop tab: /auth/validate-keys only
knows how to probe Google Places and OpenWeatherMap. */}
<MProviderBlock title={t('admin.amapKey')}>
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">{t('admin.amapKeyHintShort')}</p>
<MAdminSecretInput
aria-label={t('admin.amapKey')}
value={amapKey}
onChange={(e) => setAmapKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
/>
</MProviderBlock>
<MProviderBlock title={t('admin.unsplashKey')}>
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">{t('admin.unsplashKeyHint')}</p>
<MAdminSecretInput
aria-label={t('admin.unsplashKey')}
value={unsplashKey}
onChange={(e) => setUnsplashKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
/>
</MProviderBlock>
</>)}
<MAdminField label={t('admin.placesProvider.title')} hint={t('admin.placesProvider.subtitle')}>
{/* The app's own picker rather than a bare <select>: a native one is
drawn by the operating system and was the one control on this page
that did not belong to TREK. Same sheet the settings selects use. */}
<button
type="button"
disabled={savingPlacesProvider}
onClick={() => setProviderPickerOpen(true)}
className="flex h-[42px] w-full items-center gap-2 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-left text-[0.84375rem] text-m-ink disabled:opacity-50"
>
<span className="min-w-0 flex-1 truncate">{t(`admin.placesProvider.${placesProvider}`)}</span>
<ChevronDown size={15} strokeWidth={2.2} className="flex-none text-m-faint" aria-hidden />
</button>
<MSetPickerSheet
open={providerPickerOpen}
onClose={() => setProviderPickerOpen(false)}
title={t('admin.placesProvider.title')}
value={placesProvider}
onSelect={(value) => { if (value !== placesProvider) void handleSavePlacesProvider(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') },
]}
/>
{/* From app-config, like the desktop card: the key fields are empty
on a managed install and on an operator key set by environment. */}
{((placesProvider === 'google' && !hasMapsKey) || (placesProvider === 'amap' && !hasAmapKey)) && (
<p className="mt-1 font-geist text-[0.625rem] font-bold text-[color:var(--m-st-pending)]">
{t('admin.placesProvider.missingKey')}
</p>
)}
</MAdminField>
<div>
{/* The instance's own search log, which is not Google's to spend. */}
<MAdminRow
first
title={t('admin.placeShadow.title')}
hint={t('admin.placeShadow.subtitle')}
trailing={
@@ -411,39 +436,6 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
/>
</div>
{/* Open-Meteo weather info */}
<div className="rounded-2xl border border-[color:color-mix(in_srgb,var(--m-st-confirmed)_28%,transparent)] bg-[color:color-mix(in_srgb,var(--m-st-confirmed)_10%,transparent)] p-3">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 flex-none items-center justify-center rounded-lg bg-[color:var(--m-st-confirmed)] text-white">
<Sun size={14} strokeWidth={2.2} />
</span>
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-extrabold text-m-ink">
{t('admin.weather.title')}
</span>
<span className="flex-none rounded-full bg-[color:color-mix(in_srgb,var(--m-st-confirmed)_14%,transparent)] px-2 py-[2px] font-geist text-[0.5625rem] font-bold text-[color:var(--m-st-confirmed)]">
{t('admin.weather.badge')}
</span>
</div>
<p className="mt-2 font-geist text-[0.625rem] leading-relaxed text-m-muted">
{t('admin.weather.description')}
</p>
<p className="mt-1 font-geist text-[0.59375rem] leading-relaxed text-m-faint">
{t('admin.weather.locationHint')}
</p>
<div className="mt-2 space-y-[6px]">
{[
{ title: t('admin.weather.forecast'), desc: t('admin.weather.forecastDesc') },
{ title: t('admin.weather.climate'), desc: t('admin.weather.climateDesc') },
{ title: t('admin.weather.requests'), desc: t('admin.weather.requestsDesc') },
].map((row) => (
<div key={row.title} className="rounded-xl bg-[color:var(--m-ic)] px-3 py-2">
<p className="text-[0.6875rem] font-bold text-m-ink">{row.title}</p>
<p className="mt-[1px] font-geist text-[0.59375rem] text-m-muted">{row.desc}</p>
</div>
))}
</div>
</div>
{!managed && (
<MAdminButton busy={savingKeys} onClick={handleSaveApiKeys}>
{t('common.save')}
@@ -0,0 +1,227 @@
import { useState } from 'react'
import { Check, ChevronRight, Globe2, KeyRound, Library, ShieldOff, WifiOff, X } from 'lucide-react'
import TrekMark from '../../../components/shared/TrekMark'
import { useTranslation } from '../../../i18n'
/**
* The API-keys card's two building blocks, in the phone's own material.
*
* The desktop tab was rebuilt around one idea: a key should be the exception
* rather than the thing you are expected to paste. The TREK Places API leads,
* the paid providers follow as comparable blocks, and the "recommended" mark
* belongs to the free option instead of to the Google field. The phone still
* had the older shape, where Google wore the recommendation and TREK was not on
* the screen at all, which is the opposite advice on the same setting.
*
* Rebuilt rather than shared with the desktop component: every surface, radius
* and type size here comes from the mobile tokens, and the desktop card is a
* two-column layout with a hover-revealed disclosure. What is shared is the
* 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. */
function ChipGroup({
icon,
label,
items,
note,
dashed = false,
}: {
icon: React.ReactNode
label: string
items: string[]
note: string
dashed?: boolean
}) {
return (
<div className="rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px]">
<p className="flex items-center gap-[6px] text-[0.6875rem] font-bold text-m-ink">
{icon}
{label}
</p>
<ul className="mt-2 flex flex-wrap gap-[5px]">
{items.map(item => (
<li key={item} className={`${chip} ${dashed ? 'border-dashed text-m-faint' : 'text-m-muted'}`}>
{item}
</li>
))}
</ul>
<p className="mt-2 font-geist text-[0.625rem] leading-relaxed text-m-faint">{note}</p>
</div>
)
}
/** The TREK Places API, above the paid keys because it is the alternative to them. */
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') },
]
// 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'),
]
return (
<div className="relative mt-[18px]">
{/* The recommendation sits on the border, as on the desktop card, and small:
a wide coloured banner would shout over the settings beside it. */}
<span className="pointer-events-none absolute -top-2 left-3 z-10 rounded-md bg-m-act px-2 py-[1px] font-geist text-[0.5625rem] font-bold tracking-[.12em] text-m-actfg uppercase">
{t('admin.trekApi.badgeDefault')}
</span>
<div className="overflow-hidden rounded-[16px] border border-[color:var(--m-act)] bg-[color:var(--m-ic)]">
<div className="px-[14px] pt-[18px] pb-[13px]">
<TrekMark className="h-6 w-auto text-m-ink" aria-label="TREK Places API" />
<p className="mt-[9px] text-[0.8125rem] leading-relaxed text-m-muted">{t('admin.trekApi.tagline')}</p>
<ul className="mt-3 grid grid-cols-1 gap-[7px]">
{facts.map(({ Icon, text }) => (
<li key={text} className="flex items-center gap-2 font-geist text-[0.6875rem] text-m-muted">
<Icon size={13} strokeWidth={2} className="flex-none text-m-faint" aria-hidden />
{text}
</li>
))}
</ul>
</div>
{/* A button rather than <details>: the phone shell styles its own
disclosures and Safari draws a marker on the summary that no reset
reaches reliably. */}
<button
type="button"
onClick={() => setOpen(v => !v)}
aria-expanded={open}
className="flex w-full items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[14px] py-[10px] text-left"
>
<ChevronRight
size={15}
strokeWidth={2.2}
className={`flex-none text-m-faint transition-transform ${open ? 'rotate-90' : ''}`}
aria-hidden
/>
<span className="text-[0.8125rem] font-semibold text-m-muted">{t('admin.trekApi.more')}</span>
</button>
{open && (
<div className="space-y-[9px] px-[14px] pt-1 pb-[14px]">
<ChipGroup
icon={<Check size={13} strokeWidth={2.4} className="text-[color:var(--m-st-confirmed)]" aria-hidden />}
label={t('admin.trekApi.included')}
items={fields}
note={t('admin.trekApi.includedNote')}
/>
<ChipGroup
icon={<X size={13} strokeWidth={2.4} className="text-m-faint" aria-hidden />}
label={t('admin.trekApi.notIncluded')}
items={[t('admin.trekApi.notRatings'), t('admin.trekApi.notPhotos')]}
note={t('admin.trekApi.notIncludedNote')}
dashed
/>
<ChipGroup
icon={<Library size={13} strokeWidth={2.2} className="text-m-faint" aria-hidden />}
label={t('admin.trekApi.sourcesLabel')}
items={SOURCES}
note={t('admin.trekApi.sourcesNote')}
/>
</div>
)}
</div>
</div>
)
}
/**
* A folded group of settings that belong to the block above them.
*
* Closed to begin with, and on purpose: the four Google switches are set once
* when a key is pasted and never touched again, and open they were longer than
* everything else on the card put together.
*/
export function MBlockDisclosure({ label, children }: { label: string; children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<div className="-mx-[14px] -mb-[13px] mt-1 border-t border-[color:var(--m-rowbr)]">
<button
type="button"
onClick={() => setOpen(v => !v)}
aria-expanded={open}
className="flex w-full items-center gap-2 px-[14px] py-[10px] text-left"
>
<ChevronRight
size={15}
strokeWidth={2.2}
className={`flex-none text-m-faint transition-transform ${open ? 'rotate-90' : ''}`}
aria-hidden
/>
<span className="text-[0.8125rem] font-semibold text-m-muted">{label}</span>
</button>
{open && <div className="px-[14px] pb-[10px]">{children}</div>}
</div>
)
}
/**
* One paid provider, in the same shape as the TREK block above it, so the card
* reads as a list of comparable options rather than one highlighted thing with
* loose fields underneath.
*/
export function MProviderBlock({
title,
badge,
tone = 'muted',
children,
}: {
title: string
badge?: string
/** `caution` marks a provider that costs something other than money. Never `danger`: using a key is a legitimate choice. */
tone?: 'muted' | 'caution'
children: React.ReactNode
}) {
return (
// The ribbon rides on the top border, so the block needs room above it or the
// capitals touch whatever ends the block before.
<div className={`relative ${badge ? 'mt-[18px]' : 'mt-3'}`}>
{badge && (
<span
className={`pointer-events-none absolute -top-2 left-3 z-10 rounded-md border px-2 py-[1px] font-geist text-[0.5625rem] font-bold tracking-[.12em] uppercase ${
tone === 'caution'
? 'border-[color:var(--m-st-pending)] bg-[color:color-mix(in_srgb,var(--m-st-pending)_16%,var(--m-sheetop))] text-[color:var(--m-st-pending)]'
: 'border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] text-m-faint'
}`}
>
{badge}
</span>
)}
<div className="overflow-hidden rounded-[16px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
<div className="px-[14px] pt-[18px] pb-[13px]">
<p className="text-[0.8125rem] font-semibold text-m-ink">{title}</p>
<div className="mt-[10px] space-y-3">{children}</div>
</div>
</div>
</div>
)
}
@@ -300,7 +300,7 @@ describe('AdminSettingsTab', () => {
// failing, which is quiet enough to be mistaken for the provider working.
renderTab({ placesProvider: 'amap', hasAmapKey: false });
expect(
within(card('API Keys')).getByText(/falls back to OpenStreetMap/i),
within(card('API Keys')).getByText(/TREK index and OpenStreetMap alone/i),
).toBeInTheDocument();
});