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 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 ( ) } /** A warning in a card body: what a setting will not do as things stand. */ function Callout({ children }: { children: React.ReactNode }) { return (

{children}

) } /** * 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 label: string onToggle: () => void }) { return (
onChange(e.target.value)} {...inputProps} className={`${INPUT} pe-10 font-geist`} />
) } /** 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 (
) } // "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 = (
setAllowedFileTypes(e.target.value)} placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv" className={`${INPUT} font-geist`} /> {t('admin.fileTypesFormat')}
) return (
{/* 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. */}
{/* Authentication Methods */} {envOverrideOidcOnly && {t('admin.envOverrideHint')}} handleToggleAuthSetting('password_login', !passwordLogin, setPasswordLogin)} /> } /> handleToggleAuthSetting('password_registration', !passwordRegistration, setPasswordRegistration)} /> } /> {/* SSO Login (only when OIDC configured) */} {oidcConfigured && ( handleToggleAuthSetting('oidc_login', !oidcLogin, setOidcLogin)} /> } /> )} {/* SSO Registration (only when OIDC configured) */} {oidcConfigured && ( handleToggleAuthSetting('oidc_registration', !oidcRegistration, setOidcRegistration)} /> } /> )} {/* Passkey (WebAuthn) login */} handleToggleAuthSetting('passkey_login', !passkeyLogin, setPasskeyLogin)} /> } /> {passkeyLogin && !passkeyConfigured && {t('admin.passkey.notConfigured')}} {/* 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 && (<> setWebauthnRpId(e.target.value)} placeholder="trek.example.org" className={`${INPUT} font-geist`} /> setWebauthnOrigins(e.target.value)} placeholder="https://trek.example.org" className={`${INPUT} font-geist`} /> )} {/* Require 2FA for all users */} handleToggleRequireMfa(!requireMfa)} /> } /> {/* 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 */
setOidcConfig(c => ({ ...c, display_name: e.target.value }))} placeholder={t('admin.oidcDisplayNamePlaceholder')} className={INPUT} /> setOidcConfig(c => ({ ...c, issuer: e.target.value }))} placeholder='https://accounts.google.com' className={`${INPUT} font-geist`} />
/.well-known/openid-configuration' })} > setOidcConfig(c => ({ ...c, discovery_url: e.target.value }))} placeholder='https://auth.example.com/application/o/trek/.well-known/openid-configuration' className={`${INPUT} font-geist`} />
setOidcConfig(c => ({ ...c, client_id: e.target.value }))} className={`${INPUT} font-geist`} /> setOidcConfig(c => ({ ...c, client_secret: e.target.value }))} placeholder={oidcConfig.client_secret_set ? '••••••••' : ''} className={`${INPUT} font-geist`} />
)} {!managed && fileTypesCard}
{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 && } {/* 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 && ( {!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. */} {t('admin.transitProvider.subtitle')} {/* 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. */} { // 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') }, ]} /> {transitProvider === 'google' ? t('admin.transitProvider.googleHint') : t('admin.transitProvider.transitousHint')} {/* 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 && ( {t('admin.transitProvider.noKeyWarning')} )} {transitProvider === 'google' && transitGoogleKeySource === 'user-row' && ( {t('admin.transitProvider.personalKeyWarning')} )} )} {/* 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). */} {t('admin.placesProvider.subtitle')}
{ 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') }, ]} />
{/* 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)) && ( {t('admin.placesProvider.missingKey')} )}
{/* 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. */} togglePlaceShadow(!placeShadowEnabled)} /> } />
)} {/* 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 && ( {/* 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. */} {/* Said before the field, not after it. Someone about to paste a key should read this while deciding, not once they already have. */} {t('admin.googleCaveat.body')}
toggleKey('maps')} />
{t('admin.mapsKeyHintLong')} {validation.maps === true && (
}>{t('admin.keyValid')}
)} {validation.maps === false && (
}>{t('admin.keyInvalid')}
)}
{/* 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. */} togglePlacesPhotos(!placesPhotosEnabled)} /> } /> togglePlacesAutocomplete(!placesAutocompleteEnabled)} /> } /> togglePlacesDetails(!placesDetailsEnabled)} /> } /> 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. */} } />
toggleKey('unsplash')} /> {t('admin.unsplashKeyHint')} {/* 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. */} toggleKey('amap')} /> {t('admin.amapKeyHint')}
)}
{/* 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 */ setShowRotateJwtModal(true)} className={SETTINGS_BUTTON_DANGER} style={fs(13, 'body')} > {t('admin.rotateJwt.button')} } /> )}
) }