Files
TREK/client/src/pages/admin/AdminSettingsTab.tsx
T
Maurice 86513fda0d Managed mode: hand place search, routing, plugins and docs to the operator
On a managed install the operator runs place search and routing, the plugin
runtime, the integrations and the documentation, so the admin and the users
no longer see switches and guides for them:

- The place search and routing card, the transit provider and the search log
  are gone from the admin settings (desktop and mobile). places_provider is a
  managed-locked setting, and the transit provider, search log and wanderer
  routes refuse writes there. Share place details stays the admin's, as a card
  of its own, hidden when TREK_PLACES_CONTRIBUTE switches it off.
- File types and trip reminders move to the right column, so the columns stay
  balanced.
- Plugins disappear from the admin and the user settings, wanderer from Tours.
- The Help & Docs page and its links are not offered. The help center stays and
  leaves out guides about what the operator runs (keys, OIDC, backups, storage,
  releases, plugins, document sync) and about integrations that are off.
- Mapbox GL is offered only with the operator's token, Amap tiles only where
  the operator set Amap up.
- The mobile admin no longer shows OIDC and the WebAuthn relying party fields,
  matching the desktop tab.

For every install:

- Notification matrices list only events the install can raise: no plugin row
  without the plugin runtime, no Vacay, collection, photo, Synology, wanderer or
  packing rows while those are off. On a managed install the release and
  replica alerts are left out.
- The journey entry editor offers External photos only where a photo provider
  is switched on, as the phone already did.
- app-config is asked again after a failed answer. A page opened while the
  server restarted kept every default for the session, which on a managed
  install brought back the operator's settings.
2026-11-03 08:44:45 +01:00

692 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react'
import { adminApi } from '../../api/client'
import { getApiErrorMessage } from '../../types'
import {
AlertTriangle, CheckCircle, Compass, Eye, EyeOff, FileType, Fingerprint, KeyRound, Loader2, LockKeyhole, LogIn,
RefreshCw, Save, ShieldCheck, XCircle,
} from 'lucide-react'
import ToggleSwitch from '../../components/Settings/ToggleSwitch'
import CustomSelect from '../../components/shared/CustomSelect'
import { fs } from '../../components/shared/DialogShell'
import { EditorField, GRID_2, INPUT } from '../../components/shared/dialogParts'
import {
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard, SettingsHint, StatusPill,
} from '../../components/Settings/settingsKit'
import GoogleOptions from './GoogleOptions'
import GoogleDailyLimitRow from '../../components/Admin/GoogleDailyLimitRow'
import ProviderBlock from './ProviderBlock'
import TrekApiCard from './TrekApiCard'
import PlaceContribPanel from '../../components/Admin/PlaceContribPanel'
import { useAdminSettingsActions } from '../../components/Admin/useAdminSettingsActions'
import { placesGoogleOnlyHint } from '../../utils/placeSource'
import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin'
interface AdminSettingsTabProps {
admin: ReturnType<typeof useAdmin>
t: TranslationFn
}
/**
* ToggleSwitch's look for the login switches that can be locked: the last way
* in may not be switched off, and an OIDC_ONLY environment pins the password
* ones. ToggleSwitch itself has no disabled state, so the same track and knob
* are drawn here with one. The title carries the lockout reason.
*/
function LockableSwitch({ on, onToggle, label, disabled, title }: {
on: boolean
onToggle: () => void
label: string
disabled?: boolean
title?: string
}) {
return (
<button type="button" onClick={onToggle} disabled={disabled} title={title} aria-pressed={on} aria-label={label}
className="group relative h-6 w-11 flex-none cursor-pointer rounded-full border-0 p-0 outline-none transition-colors duration-200 [transition-timing-function:cubic-bezier(0.32,0.72,0,1)] focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--bg-card)] disabled:cursor-default disabled:opacity-50"
style={{
background: on ? 'var(--accent)' : 'var(--border-primary)',
boxShadow: 'inset 0 1px 2px color-mix(in srgb, var(--text-primary) 12%, transparent)',
}}>
<span className="absolute top-0.5 h-5 w-5 rounded-full transition-[left,transform] duration-200 [transition-timing-function:cubic-bezier(0.32,0.72,0,1)] group-enabled:group-active:scale-95"
style={{ left: on ? 22 : 2, background: on ? 'var(--accent-text)' : 'var(--bg-card)', boxShadow: 'var(--shadow-sm)' }} />
</button>
)
}
/** A warning in a card body: what a setting will not do as things stand. */
function Callout({ children }: { children: React.ReactNode }) {
return (
<p className="m-0 flex items-start gap-2 rounded-[12px] bg-warning-soft px-3 py-2.5 leading-snug text-warning" style={fs(12, 'body')}>
<AlertTriangle size={14} strokeWidth={2.2} className="mt-px flex-none" />
<span className="min-w-0">{children}</span>
</p>
)
}
/**
* A secret key field with its show/hide eye inside the box. The eye carries the
* key's name as its accessible name and is the input's sibling, which is how
* the tests find each field.
*/
function KeyField({ shown, value, onChange, inputProps, label, onToggle }: {
shown: boolean
value: string
onChange: (value: string) => void
inputProps: React.InputHTMLAttributes<HTMLInputElement>
label: string
onToggle: () => void
}) {
return (
<div className="relative min-w-0 flex-1">
<input
type={shown ? 'text' : 'password'}
value={value}
onChange={e => onChange(e.target.value)}
{...inputProps}
className={`${INPUT} pe-10 font-geist`}
/>
<button
type="button"
onClick={onToggle}
aria-label={label}
className="absolute end-1.5 top-1/2 grid h-7 w-7 -translate-y-1/2 place-items-center rounded-[8px] text-content-faint hover:bg-surface-hover hover:text-content"
>
{shown ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
)
}
/** The save at the foot of a card body, on the right like a dialog's main action. */
function SaveBar({ onClick, saving, label }: { onClick: () => void; saving: boolean; label: string }) {
return (
<div className="flex justify-end">
<button type="button" onClick={onClick} disabled={saving} className={SETTINGS_BUTTON_PRIMARY} style={fs(13, 'body')}>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{label}
</button>
</div>
)
}
// "Settings" admin tab: auth methods, require-MFA, allowed file types, API keys,
// OIDC config and the danger zone. Pure layout around the useAdmin hook.
export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): React.ReactElement {
const {
toast,
placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled,
placesGoogleOnly, handleTogglePlacesGoogleOnly,
transitProvider, setTransitProviderState,
transitGoogleKeySource, setTransitGoogleKeySource,
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,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, hasMapsKey, hasAmapKey, keyInputProps, mapsKeyTestable, showKeys, savingKeys, validating, validation,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
managed,
setShowRotateJwtModal,
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
// On a managed install the right column loses place search, routing and the
// keys to the operator, so file types move over and the columns stay even.
const fileTypesCard = (
<SettingsCard icon={FileType} title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')}>
<div>
<input
type="text"
aria-label={t('admin.fileTypes')}
value={allowedFileTypes}
onChange={e => setAllowedFileTypes(e.target.value)}
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
className={`${INPUT} font-geist`}
/>
<SettingsHint className="mt-1.5">{t('admin.fileTypesFormat')}</SettingsHint>
</div>
<SaveBar
label={t('common.save')}
saving={savingFileTypes}
onClick={saveFileTypes}
/>
</SettingsCard>
)
return (
<div>
{/* Two columns from xl up. Nearly every card here is "label left,
switch right", so on a wide screen the middle stayed empty while the
page scrolled for ages. Two explicit columns rather than a grid over
the flat list: a plain grid pairs cards row by row and leaves a hole
under whichever of the pair is shorter, and these differ by hundreds
of pixels. Grouped by subject, not by height — access and identity
left, integrations and data right. The cards carry their own bottom
margin, so only the column gap is set here. */}
<div className="grid grid-cols-1 gap-x-5 xl:grid-cols-2 xl:items-start">
<div className="min-w-0">
{/* Authentication Methods */}
<SettingsCard icon={LockKeyhole} title={t('admin.authMethods')}>
{envOverrideOidcOnly && <Callout>{t('admin.envOverrideHint')}</Callout>}
<SettingRows>
<SettingRow
label={t('admin.passwordLogin')}
hint={t('admin.passwordLoginHint')}
control={
<LockableSwitch
on={passwordLogin}
label={t('admin.passwordLogin')}
disabled={envOverrideOidcOnly || passwordLoginLocked}
title={passwordLoginLocked ? t('admin.lockoutWarning') : undefined}
onToggle={() => handleToggleAuthSetting('password_login', !passwordLogin, setPasswordLogin)}
/>
}
/>
<SettingRow
label={t('admin.passwordRegistration')}
hint={t('admin.passwordRegistrationHint')}
control={
<LockableSwitch
on={passwordRegistration}
label={t('admin.passwordRegistration')}
disabled={envOverrideOidcOnly}
onToggle={() => handleToggleAuthSetting('password_registration', !passwordRegistration, setPasswordRegistration)}
/>
}
/>
{/* SSO Login (only when OIDC configured) */}
{oidcConfigured && (
<SettingRow
label={t('admin.oidcLogin')}
hint={t('admin.oidcLoginHint')}
control={
<LockableSwitch
on={oidcLogin}
label={t('admin.oidcLogin')}
disabled={oidcLoginLocked}
title={oidcLoginLocked ? t('admin.lockoutWarning') : undefined}
onToggle={() => handleToggleAuthSetting('oidc_login', !oidcLogin, setOidcLogin)}
/>
}
/>
)}
{/* SSO Registration (only when OIDC configured) */}
{oidcConfigured && (
<SettingRow
label={t('admin.oidcRegistration')}
hint={t('admin.oidcRegistrationHint')}
control={
<ToggleSwitch
on={oidcRegistration}
label={t('admin.oidcRegistration')}
onToggle={() => handleToggleAuthSetting('oidc_registration', !oidcRegistration, setOidcRegistration)}
/>
}
/>
)}
</SettingRows>
</SettingsCard>
{/* Passkey (WebAuthn) login */}
<SettingsCard icon={Fingerprint} title={t('admin.passkey.title')} hint={t('admin.passkey.cardHint')}>
<SettingRows>
<SettingRow
label={t('admin.passkey.login')}
hint={t('admin.passkey.loginHint')}
control={
<ToggleSwitch
on={passkeyLogin}
label={t('admin.passkey.login')}
onToggle={() => handleToggleAuthSetting('passkey_login', !passkeyLogin, setPasskeyLogin)}
/>
}
/>
</SettingRows>
{passkeyLogin && !passkeyConfigured && <Callout>{t('admin.passkey.notConfigured')}</Callout>}
{/* The domain passkeys bind to and the origins that may present them
follow from the address the instance is served on, which the operator
owns. Getting either wrong invalidates every enrolled passkey, and on
a shared parent domain a wrong RP ID reaches past this instance
entirely — so they are pinned per container, not offered here. The
switch above stays: whether to offer passkeys at all is a house rule. */}
{!managed && (<>
<EditorField label={t('admin.passkey.rpId')} htmlFor="admin-webauthn-rp-id" hint={t('admin.passkey.rpIdHint')}>
<input
id="admin-webauthn-rp-id"
type="text"
value={webauthnRpId}
onChange={e => setWebauthnRpId(e.target.value)}
placeholder="trek.example.org"
className={`${INPUT} font-geist`}
/>
</EditorField>
<EditorField label={t('admin.passkey.origins')} htmlFor="admin-webauthn-origins" hint={t('admin.passkey.originsHint')}>
<input
id="admin-webauthn-origins"
type="text"
value={webauthnOrigins}
onChange={e => setWebauthnOrigins(e.target.value)}
placeholder="https://trek.example.org"
className={`${INPUT} font-geist`}
/>
</EditorField>
<SaveBar onClick={handleSaveWebauthn} saving={savingWebauthn} label={t('common.save')} />
</>)}
</SettingsCard>
{/* Require 2FA for all users */}
<SettingsCard icon={ShieldCheck} title={t('admin.requireMfa')}>
<SettingRows>
<SettingRow
label={t('admin.requireMfa')}
hint={t('admin.requireMfaHint')}
control={
<ToggleSwitch
on={requireMfa}
label={t('admin.requireMfa')}
onToggle={() => handleToggleRequireMfa(!requireMfa)}
/>
}
/>
</SettingRows>
</SettingsCard>
{/* An issuer the instance names can assert any address as verified, and the
discovery calls leave from inside the operator’s network. Sign-on is theirs
to wire, so the fields are not offered. */}
{!managed && (
/* OIDC / SSO Configuration */
<SettingsCard icon={LogIn} title={t('admin.oidcTitle')} hint={t('admin.oidcSubtitle')}>
<div className={GRID_2}>
<EditorField label={t('admin.oidcDisplayName')} htmlFor="oidc-display-name">
<input
id="oidc-display-name"
type="text"
value={oidcConfig.display_name}
onChange={e => setOidcConfig(c => ({ ...c, display_name: e.target.value }))}
placeholder={t('admin.oidcDisplayNamePlaceholder')}
className={INPUT}
/>
</EditorField>
<EditorField label={t('admin.oidcIssuer')} htmlFor="oidc-issuer" hint={t('admin.oidcIssuerHint')}>
<input
id="oidc-issuer"
type="url"
value={oidcConfig.issuer}
onChange={e => setOidcConfig(c => ({ ...c, issuer: e.target.value }))}
placeholder='https://accounts.google.com'
className={`${INPUT} font-geist`}
/>
</EditorField>
</div>
<EditorField
label={t('admin.oidcDiscoveryUrl')}
htmlFor="oidc-discovery-url"
hint={t('admin.oidcDiscoveryUrlHint', { path: '<issuer>/.well-known/openid-configuration' })}
>
<input
id="oidc-discovery-url"
type="url"
value={oidcConfig.discovery_url}
onChange={e => setOidcConfig(c => ({ ...c, discovery_url: e.target.value }))}
placeholder='https://auth.example.com/application/o/trek/.well-known/openid-configuration'
className={`${INPUT} font-geist`}
/>
</EditorField>
<div className={GRID_2}>
<EditorField label={t('settings.oauth.clientId')} htmlFor="oidc-client-id">
<input
id="oidc-client-id"
type="text"
value={oidcConfig.client_id}
onChange={e => setOidcConfig(c => ({ ...c, client_id: e.target.value }))}
className={`${INPUT} font-geist`}
/>
</EditorField>
<EditorField label={t('settings.oauth.clientSecret')} htmlFor="oidc-client-secret">
<input
id="oidc-client-secret"
type="password"
value={oidcConfig.client_secret}
onChange={e => setOidcConfig(c => ({ ...c, client_secret: e.target.value }))}
placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
className={`${INPUT} font-geist`}
/>
</EditorField>
</div>
<SaveBar
label={t('common.save')}
saving={savingOidc}
onClick={saveOidc}
/>
</SettingsCard>
)}
{!managed && fileTypesCard}
</div>
<div className="min-w-0">
{managed && fileTypesCard}
{/* Inside the place search card elsewhere; that card is the operator's here,
sharing place details is still the admin's decision. */}
{managed && <PlaceContribPanel hideWhenSwitchedOff />}
{/* Place search and routing: TREK's own services first, then which provider
answers search and transit, and the search log. Apart from the keys, so a
keyless install reads its whole setup in one card. Everything here saves
as it is changed. */}
{/* On a managed install place search and routing are the operator's: the
services, their allowance and the providers behind them come with the
instance. Share place details stays the admin's call and has a tab of
its own there (AdminPage). */}
{!managed && (
<SettingsCard icon={Compass} title={t('admin.servicesCard.title')} hint={t('admin.servicesCard.hint')}>
<TrekApiCard t={t} managed={managed} />
{!managed && (<>
{/* Transit Backend (#1699) — Transitous has no GTFS for much of Asia,
so an install can point transit search at Google instead, on the
same key. Falls back to Transitous when no key resolves. A block of
its own rather than a row inside the Google one: picking Transitous
is a decision about a provider that has nothing to do with a key. */}
<ProviderBlock title={t('admin.transitProvider.title')}>
<SettingsHint>{t('admin.transitProvider.subtitle')}</SettingsHint>
{/* The app's own select rather than the browser's: a native option list
is drawn by the OS, so it ignores the scheme, the radius and the
text-size setting the rest of this card follows. The menu portals to
the body, which is what lets it escape the card's overflow-hidden. */}
<CustomSelect
value={transitProvider}
onChange={async value => {
// A native select stayed silent when the option already selected was
// picked again; this one reports every pick, and a no-op PUT is still
// a write on an audited settings route.
if (value === transitProvider) return
const next = value === 'google' ? 'google' : 'transitous'
const previous = transitProvider
setTransitProviderState(next)
try {
const saved = await adminApi.updateTransitProvider(next)
setTransitGoogleKeySource(saved.googleKeySource)
} catch (err: unknown) {
// The select springs back on its own; without a word that reads
// as a broken control, not as a save that failed.
setTransitProviderState(previous)
toast.error(getApiErrorMessage(err, t('common.error')))
}
}}
options={[
{ value: 'transitous', label: t('admin.transitProvider.transitous') },
{ value: 'google', label: t('admin.transitProvider.google') },
]}
/>
<SettingsHint className="-mt-1.5">
{transitProvider === 'google' ? t('admin.transitProvider.googleHint') : t('admin.transitProvider.transitousHint')}
</SettingsHint>
{/* Picking Google without a key that resolves changes nothing — the
request-time fallback is silent, so this is the only place it can
be said. 'user-row' is the subtler half: the resolver's last step
is the caller's own row, so a personal key serves this admin and
nobody else. */}
{transitProvider === 'google' && transitGoogleKeySource === null && (
<Callout>{t('admin.transitProvider.noKeyWarning')}</Callout>
)}
{transitProvider === 'google' && transitGoogleKeySource === 'user-row' && (
<Callout>{t('admin.transitProvider.personalKeyWarning')}</Callout>
)}
</ProviderBlock>
</>)}
{/* Which keyed provider answers place search. Outside the managed guard
on purpose: the operator owns the credentials, but what their users
search against is still the admin's call (see server managed.ts). */}
<ProviderBlock title={t('admin.placesProvider.title')}>
<SettingsHint>{t('admin.placesProvider.subtitle')}</SettingsHint>
<div className={savingPlacesProvider ? 'opacity-60' : undefined}>
<CustomSelect
value={placesProvider}
disabled={savingPlacesProvider}
onChange={value => {
if (value === placesProvider) return
void handleSavePlacesProvider(String(value))
}}
options={[
{ value: 'auto', label: t('admin.placesProvider.auto') },
{ value: 'google', label: t('admin.placesProvider.google') },
{ value: 'amap', label: t('admin.placesProvider.amap') },
{ value: 'openstreetmap', label: t('admin.placesProvider.openstreetmap') },
]}
/>
</div>
{/* A provider chosen without a key behind it answers with
OpenStreetMap rather than failing, which is quiet enough to be
mistaken for the provider working. Say so. Asked of app-config
and not of the fields above: those are empty on a managed
install and on an operator key that came from the environment,
and saying "falls back to OpenStreetMap" to an admin whose
search works is worse than saying nothing. */}
{((placesProvider === 'google' && !hasMapsKey) || (placesProvider === 'amap' && !hasAmapKey)) && (
<Callout>{t('admin.placesProvider.missingKey')}</Callout>
)}
</ProviderBlock>
{/* The search log sits with the index rather than with a provider:
it records which result somebody picked, so a candidate index can
be judged against real searches later. Off unless switched on,
and nothing it records leaves the instance. */}
<SettingRows>
<SettingRow
label={t('admin.placeShadow.title')}
hint={t('admin.placeShadow.subtitle')}
control={
<ToggleSwitch
on={placeShadowEnabled}
label={t('admin.placeShadow.title')}
onToggle={() => togglePlaceShadow(!placeShadowEnabled)}
/>
}
/>
</SettingRows>
</SettingsCard>
)}
{/* API Keys: only the keyed providers, Google, Unsplash and Amap. They are
the operator's on a managed install, so the card is not shown there;
which provider answers search stays the admin's call in the card above
(server managed.ts). The per-place Google switches sit under the key
because they are set once and then never touched. */}
{!managed && (
<SettingsCard icon={KeyRound} title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')}>
{/* Three comparable blocks, in the order we would have people choose
them. Weather moved out entirely: it needs no key and had nothing
to configure, so it had no business in a card about keys. */}
<ProviderBlock title={t('admin.mapsKey')}>
{/* Said before the field, not after it. Someone about to paste a key
should read this while deciding, not once they already have. */}
<SettingsHint>{t('admin.googleCaveat.body')}</SettingsHint>
<div className="flex flex-col gap-1.5">
<div className="flex gap-2">
<KeyField
shown={showKeys.maps}
value={mapsKey}
onChange={setMapsKey}
inputProps={keyInputProps('maps')}
label={t('admin.mapsKey')}
onToggle={() => toggleKey('maps')}
/>
<button type="button"
onClick={() => handleValidateKey('maps')}
disabled={!mapsKeyTestable || validating.maps}
className={`${SETTINGS_BUTTON} flex-none whitespace-nowrap`}
style={fs(13, 'body')}
>
{validating.maps ? (
<Loader2 size={14} className="animate-spin" />
) : validation.maps === true ? (
<CheckCircle size={14} className="text-success" />
) : validation.maps === false ? (
<XCircle size={14} className="text-danger" />
) : null}
{t('admin.validateKey')}
</button>
</div>
<SettingsHint>{t('admin.mapsKeyHintLong')}</SettingsHint>
{validation.maps === true && (
<div><StatusPill tone="success" icon={<span className="me-0.5 h-1.5 w-1.5 rounded-full bg-success" />}>{t('admin.keyValid')}</StatusPill></div>
)}
{validation.maps === false && (
<div><StatusPill tone="danger" icon={<span className="me-0.5 h-1.5 w-1.5 rounded-full bg-danger" />}>{t('admin.keyInvalid')}</StatusPill></div>
)}
</div>
{/* What the key may be spent on, inside the block that owns the key
rather than beside it. Each row's own subtitle says which of them
still work without one; enrichment always does, from Wikipedia
and OpenStreetMap. */}
<GoogleOptions
title={t('admin.googleOptions')}
summary={t('admin.googleOptionsSummary', {
on: [placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled, placesGoogleOnly].filter(Boolean).length,
total: 5,
})}
>
<SettingRow
label={t('admin.placesPhotos.title')}
hint={t('admin.placesPhotos.subtitle')}
control={
<ToggleSwitch
on={placesPhotosEnabled}
label={t('admin.placesPhotos.title')}
onToggle={() => togglePlacesPhotos(!placesPhotosEnabled)}
/>
}
/>
<SettingRow
label={t('admin.placesAutocomplete.title')}
hint={t('admin.placesAutocomplete.subtitle')}
control={
<ToggleSwitch
on={placesAutocompleteEnabled}
label={t('admin.placesAutocomplete.title')}
onToggle={() => togglePlacesAutocomplete(!placesAutocompleteEnabled)}
/>
}
/>
<SettingRow
label={t('admin.placesDetails.title')}
hint={t('admin.placesDetails.subtitle')}
control={
<ToggleSwitch
on={placesDetailsEnabled}
label={t('admin.placesDetails.title')}
onToggle={() => togglePlacesDetails(!placesDetailsEnabled)}
/>
}
/>
<SettingRow
label={t('admin.placesEnrich.title')}
hint={t('admin.placesEnrich.subtitle')}
control={
<ToggleSwitch
on={placesEnrichEnabled}
label={t('admin.placesEnrich.title')}
onToggle={() => togglePlacesEnrich(!placesEnrichEnabled)}
/>
}
/>
{/* The one row here that is about where a search goes rather than
what the key may be spent on. Without a key, or with Amap or
OpenStreetMap holding the slot, it is a promise the search
cannot keep, and the subtitle says so. */}
<SettingRow
label={t('admin.placesGoogleOnly.title')}
hint={t(placesGoogleOnlyHint(hasMapsKey, placesProvider))}
control={
<ToggleSwitch
on={placesGoogleOnly}
label={t('admin.placesGoogleOnly.title')}
onToggle={handleTogglePlacesGoogleOnly}
/>
}
/>
<GoogleDailyLimitRow />
</GoogleOptions>
</ProviderBlock>
<ProviderBlock title={t('admin.unsplashKey')}>
<KeyField
shown={showKeys.unsplash}
value={unsplashKey}
onChange={setUnsplashKey}
inputProps={keyInputProps('unsplash')}
label={t('admin.unsplashKey')}
onToggle={() => toggleKey('unsplash')}
/>
<SettingsHint className="-mt-1.5">{t('admin.unsplashKeyHint')}</SettingsHint>
</ProviderBlock>
{/* Amap Key. No Test button: /auth/validate-keys only knows how to
probe Google Places and OpenWeatherMap, and a button that silently
tests something else is worse than no button. */}
<ProviderBlock title={t('admin.amapKey')}>
<KeyField
shown={showKeys.amap}
value={amapKey}
onChange={setAmapKey}
inputProps={keyInputProps('amap')}
label={t('admin.amapKey')}
onToggle={() => toggleKey('amap')}
/>
<SettingsHint className="-mt-1.5">{t('admin.amapKeyHint')}</SettingsHint>
</ProviderBlock>
<SaveBar onClick={handleSaveApiKeys} saving={savingKeys} label={t('common.save')} />
</SettingsCard>
)}
</div>
</div>
{/* Full width, and last. A destructive action should not sit beside a
harmless toggle where a mis-aimed click can reach it. */}
{/* Rotating the secret signs every user out and fixes nothing an instance admin
can reach: the file it writes belongs to the host. */}
{!managed && (
/* Danger Zone */
<SettingsCard icon={AlertTriangle} title={t('admin.danger.title')} tone="danger">
<SettingRows>
<SettingRow
label={t('admin.rotateJwt.title')}
hint={t('admin.rotateJwt.hint')}
control={
<button type="button"
onClick={() => setShowRotateJwtModal(true)}
className={SETTINGS_BUTTON_DANGER}
style={fs(13, 'body')}
>
<RefreshCw size={14} />
{t('admin.rotateJwt.button')}
</button>
}
/>
</SettingRows>
</SettingsCard>
)}
</div>
)
}