Files
TREK/client/src/mobile/screens/admin/MAdminSettingsSection.tsx
T

512 lines
21 KiB
TypeScript

import { AlertTriangle, RefreshCw, Sun } 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 {
MAdminButton,
MAdminCard,
MAdminCardHead,
MAdminField,
MAdminInput,
MAdminRow,
MAdminSecretInput,
} from './MAdminUi'
interface MAdminSettingsSectionProps {
admin: ReturnType<typeof useAdmin>
t: TranslationFn
}
// Settings section: auth methods, passkey login, require-MFA, allowed file
// 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 {
toast,
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled,
placesPhotosEnabled, setPlacesPhotosEnabledState,
placesAutocompleteEnabled, setPlacesAutocompleteEnabledState,
placesDetailsEnabled, setPlacesDetailsEnabledState,
placesEnrichEnabled, setPlacesEnrichEnabledState,
placeShadowEnabled, setPlaceShadowEnabledState,
oidcConfig, setOidcConfig, savingOidc, setSavingOidc,
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
envOverrideOidcOnly, oidcConfigured, requireMfa,
passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, savingKeys, validating, validation,
placesProvider, savingPlacesProvider, handleSavePlacesProvider,
setShowRotateJwtModal,
handleToggleAuthSetting, handleToggleRequireMfa,
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)
}
}
return (
<div className="space-y-3">
{/* Authentication methods */}
<MAdminCard>
<MAdminCardHead title={t('admin.authMethods')} />
{envOverrideOidcOnly && (
<p className="mb-2 rounded-xl border border-[color:color-mix(in_srgb,var(--m-st-pending)_28%,transparent)] bg-[color:color-mix(in_srgb,var(--m-st-pending)_10%,transparent)] px-3 py-2 font-geist text-[0.625rem] leading-relaxed text-m-ink">
{t('admin.envOverrideHint')}
</p>
)}
<MAdminRow
title={t('admin.passwordLogin')}
hint={t('admin.passwordLoginHint')}
trailing={
<MToggle
checked={passwordLogin}
disabled={envOverrideOidcOnly || (!passwordLogin && !oidcLogin)}
ariaLabel={t('admin.passwordLogin')}
onChange={(v) => handleToggleAuthSetting('password_login', v, setPasswordLogin)}
/>
}
/>
<MAdminRow
title={t('admin.passwordRegistration')}
hint={t('admin.passwordRegistrationHint')}
trailing={
<MToggle
checked={passwordRegistration}
disabled={envOverrideOidcOnly}
ariaLabel={t('admin.passwordRegistration')}
onChange={(v) => handleToggleAuthSetting('password_registration', v, setPasswordRegistration)}
/>
}
/>
{oidcConfigured && (
<MAdminRow
title={t('admin.oidcLogin')}
hint={t('admin.oidcLoginHint')}
trailing={
<MToggle
checked={oidcLogin}
disabled={!passwordLogin && oidcLogin}
ariaLabel={t('admin.oidcLogin')}
onChange={(v) => handleToggleAuthSetting('oidc_login', v, setOidcLogin)}
/>
}
/>
)}
{oidcConfigured && (
<MAdminRow
title={t('admin.oidcRegistration')}
hint={t('admin.oidcRegistrationHint')}
trailing={
<MToggle
checked={oidcRegistration}
ariaLabel={t('admin.oidcRegistration')}
onChange={(v) => handleToggleAuthSetting('oidc_registration', v, setOidcRegistration)}
/>
}
/>
)}
</MAdminCard>
{/* Passkey login */}
<MAdminCard>
<MAdminCardHead title={t('admin.passkey.title')} hint={t('admin.passkey.cardHint')} />
<MAdminRow
title={t('admin.passkey.login')}
hint={t('admin.passkey.loginHint')}
trailing={
<MToggle
checked={passkeyLogin}
ariaLabel={t('admin.passkey.login')}
onChange={(v) => handleToggleAuthSetting('passkey_login', v, setPasskeyLogin)}
/>
}
/>
{passkeyLogin && !passkeyConfigured && (
<p className="mb-3 flex items-start gap-2 rounded-xl border border-[color:color-mix(in_srgb,var(--m-st-pending)_28%,transparent)] bg-[color:color-mix(in_srgb,var(--m-st-pending)_10%,transparent)] px-3 py-2 font-geist text-[0.625rem] leading-relaxed text-m-ink">
<AlertTriangle size={14} className="mt-[1px] flex-none text-[color:var(--m-st-pending)]" />
{t('admin.passkey.notConfigured')}
</p>
)}
<div className="space-y-3">
<MAdminField label={t('admin.passkey.rpId')} hint={t('admin.passkey.rpIdHint')}>
<MAdminInput
type="text"
value={webauthnRpId}
onChange={(e) => setWebauthnRpId(e.target.value)}
placeholder="trek.example.org"
/>
</MAdminField>
<MAdminField label={t('admin.passkey.origins')} hint={t('admin.passkey.originsHint')}>
<MAdminInput
type="text"
value={webauthnOrigins}
onChange={(e) => setWebauthnOrigins(e.target.value)}
placeholder="https://trek.example.org"
/>
</MAdminField>
<MAdminButton busy={savingWebauthn} onClick={handleSaveWebauthn}>
{t('common.save')}
</MAdminButton>
</div>
</MAdminCard>
{/* Require 2FA */}
<MAdminCard>
<MAdminRow
first
title={t('admin.requireMfa')}
hint={t('admin.requireMfaHint')}
trailing={
<MToggle checked={requireMfa} ariaLabel={t('admin.requireMfa')} onChange={handleToggleRequireMfa} />
}
/>
</MAdminCard>
{/* Allowed file types */}
<MAdminCard>
<MAdminCardHead title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')} />
<div className="space-y-3">
<MAdminField label={t('admin.fileTypes')} hint={t('admin.fileTypesFormat')}>
<MAdminInput
type="text"
value={allowedFileTypes}
onChange={(e) => setAllowedFileTypes(e.target.value)}
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
/>
</MAdminField>
<MAdminButton busy={savingFileTypes} onClick={saveFileTypes}>
{t('common.save')}
</MAdminButton>
</div>
</MAdminCard>
{/* API keys */}
<MAdminCard>
<MAdminCardHead title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')} />
<div className="space-y-3">
<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')}
>
<div className="flex gap-2">
<div className="min-w-0 flex-1">
<MAdminSecretInput
aria-label={t('admin.mapsKey')}
value={mapsKey}
onChange={(e) => setMapsKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
/>
</div>
<MAdminButton
variant="ghost"
disabled={!mapsKey}
busy={!!validating.maps}
onClick={() => handleValidateKey('maps')}
className="h-[42px]"
>
{t('admin.validateKey')}
</MAdminButton>
</div>
{validation.maps === true && (
<p className="mt-1 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)]">
{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.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>
{((placesProvider === 'google' && !mapsKey) || (placesProvider === 'amap' && !amapKey)) && (
<p className="mt-1 font-geist text-[0.625rem] font-bold text-[color:var(--m-st-warn,#b45309)]">
{t('admin.placesProvider.missingKey')}
</p>
)}
</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>
<div>
<MAdminRow
title={t('admin.placesPhotos.title')}
hint={t('admin.placesPhotos.subtitle')}
trailing={
<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)
}
}}
/>
}
/>
<MAdminRow
title={t('admin.placesAutocomplete.title')}
hint={t('admin.placesAutocomplete.subtitle')}
trailing={
<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)
}
}}
/>
}
/>
<MAdminRow
title={t('admin.placesDetails.title')}
hint={t('admin.placesDetails.subtitle')}
trailing={
<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)
}
}}
/>
}
/>
<MAdminRow
title={t('admin.placesEnrich.title')}
hint={t('admin.placesEnrich.subtitle')}
trailing={
<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)
}
}}
/>
}
/>
<MAdminRow
title={t('admin.placeShadow.title')}
hint={t('admin.placeShadow.subtitle')}
trailing={
<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)
}
}}
/>
}
/>
</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>
<MAdminButton busy={savingKeys} onClick={handleSaveApiKeys}>
{t('common.save')}
</MAdminButton>
</div>
</MAdminCard>
{/* OIDC / SSO */}
<MAdminCard>
<MAdminCardHead title={t('admin.oidcTitle')} hint={t('admin.oidcSubtitle')} />
<div className="space-y-3">
<MAdminField label={t('admin.oidcDisplayName')}>
<MAdminInput
type="text"
value={oidcConfig.display_name}
onChange={(e) => setOidcConfig((c) => ({ ...c, display_name: e.target.value }))}
placeholder="z.B. Google, Authentik, Keycloak"
/>
</MAdminField>
<MAdminField label={t('admin.oidcIssuer')} hint={t('admin.oidcIssuerHint')}>
<MAdminInput
type="url"
value={oidcConfig.issuer}
onChange={(e) => setOidcConfig((c) => ({ ...c, issuer: e.target.value }))}
placeholder="https://accounts.google.com"
/>
</MAdminField>
<MAdminField label="Discovery URL" hint="Override the auto-constructed discovery URL. Required for providers like Authentik where the endpoint is not at <issuer>/.well-known/openid-configuration.">
<MAdminInput
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"
/>
</MAdminField>
<MAdminField label="Client ID">
<MAdminInput
type="text"
value={oidcConfig.client_id}
onChange={(e) => setOidcConfig((c) => ({ ...c, client_id: e.target.value }))}
/>
</MAdminField>
<MAdminField label="Client Secret">
<MAdminInput
type="password"
value={oidcConfig.client_secret}
onChange={(e) => setOidcConfig((c) => ({ ...c, client_secret: e.target.value }))}
placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
/>
</MAdminField>
<MAdminButton busy={savingOidc} onClick={saveOidc}>
{t('common.save')}
</MAdminButton>
</div>
</MAdminCard>
{/* Danger zone */}
<MAdminCard className="border-[color:color-mix(in_srgb,var(--m-st-danger)_28%,transparent)]">
<div className="mb-1 flex items-center gap-2 text-[color:var(--m-st-danger)]">
<AlertTriangle size={14} strokeWidth={2.2} />
<span className="text-[0.875rem] font-extrabold">Danger Zone</span>
</div>
<MAdminRow
first
title="Rotate JWT Secret"
hint="Generate a new JWT signing secret. All active sessions will be invalidated immediately."
trailing={
<MAdminButton variant="danger" onClick={() => setShowRotateJwtModal(true)}>
<RefreshCw size={12} strokeWidth={2.2} />
Rotate
</MAdminButton>
}
/>
</MAdminCard>
</div>
)
}