Spaces:
Sleeping
Sleeping
| import React from 'react' | |
| import { adminApi, authApi } from '../../api/client' | |
| import { getApiErrorMessage } from '../../types' | |
| import { Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, Sun, RefreshCw, AlertTriangle } from 'lucide-react' | |
| import type { TranslationFn } from '../../types' | |
| import type { useAdmin } from './useAdmin' | |
| interface AdminSettingsTabProps { | |
| admin: ReturnType<typeof useAdmin> | |
| t: TranslationFn | |
| } | |
| // "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, | |
| setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, | |
| placesPhotosEnabled, setPlacesPhotosEnabledState, | |
| placesAutocompleteEnabled, setPlacesAutocompleteEnabledState, | |
| placesDetailsEnabled, setPlacesDetailsEnabledState, | |
| 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, showKeys, savingKeys, validating, validation, | |
| setShowRotateJwtModal, | |
| handleToggleAuthSetting, handleToggleRequireMfa, | |
| toggleKey, handleSaveApiKeys, handleValidateKey, | |
| } = admin | |
| return ( | |
| <div className="space-y-6"> | |
| {/* Authentication Methods */} | |
| <div className="bg-white rounded-xl border border-slate-200 overflow-hidden"> | |
| <div className="px-6 py-4 border-b border-slate-100"> | |
| <h2 className="font-semibold text-slate-900">{t('admin.authMethods')}</h2> | |
| </div> | |
| <div className="p-6 space-y-5"> | |
| {envOverrideOidcOnly && ( | |
| <p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2"> | |
| {t('admin.envOverrideHint')} | |
| </p> | |
| )} | |
| {/* Password Login */} | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-medium text-slate-700">{t('admin.passwordLogin')}</p> | |
| <p className="text-xs text-slate-400 mt-0.5">{t('admin.passwordLoginHint')}</p> | |
| </div> | |
| <button | |
| disabled={envOverrideOidcOnly || (!passwordLogin && !oidcLogin)} | |
| onClick={() => handleToggleAuthSetting('password_login', !passwordLogin, setPasswordLogin)} | |
| title={!passwordLogin && !oidcLogin ? t('admin.lockoutWarning') : undefined} | |
| className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${passwordLogin ? 'bg-content' : 'bg-edge'}`} | |
| > | |
| <span | |
| className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" | |
| style={{ transform: passwordLogin ? 'translateX(20px)' : 'translateX(0)' }} | |
| /> | |
| </button> | |
| </div> | |
| {/* Password Registration */} | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-medium text-slate-700">{t('admin.passwordRegistration')}</p> | |
| <p className="text-xs text-slate-400 mt-0.5">{t('admin.passwordRegistrationHint')}</p> | |
| </div> | |
| <button | |
| disabled={envOverrideOidcOnly} | |
| onClick={() => handleToggleAuthSetting('password_registration', !passwordRegistration, setPasswordRegistration)} | |
| className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${passwordRegistration ? 'bg-content' : 'bg-edge'}`} | |
| > | |
| <span | |
| className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" | |
| style={{ transform: passwordRegistration ? 'translateX(20px)' : 'translateX(0)' }} | |
| /> | |
| </button> | |
| </div> | |
| {/* SSO Login (only when OIDC configured) */} | |
| {oidcConfigured && ( | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-medium text-slate-700">{t('admin.oidcLogin')}</p> | |
| <p className="text-xs text-slate-400 mt-0.5">{t('admin.oidcLoginHint')}</p> | |
| </div> | |
| <button | |
| disabled={!passwordLogin && oidcLogin} | |
| onClick={() => handleToggleAuthSetting('oidc_login', !oidcLogin, setOidcLogin)} | |
| title={!passwordLogin && oidcLogin ? t('admin.lockoutWarning') : undefined} | |
| className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${oidcLogin ? 'bg-content' : 'bg-edge'}`} | |
| > | |
| <span | |
| className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" | |
| style={{ transform: oidcLogin ? 'translateX(20px)' : 'translateX(0)' }} | |
| /> | |
| </button> | |
| </div> | |
| )} | |
| {/* SSO Registration (only when OIDC configured) */} | |
| {oidcConfigured && ( | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-medium text-slate-700">{t('admin.oidcRegistration')}</p> | |
| <p className="text-xs text-slate-400 mt-0.5">{t('admin.oidcRegistrationHint')}</p> | |
| </div> | |
| <button | |
| onClick={() => handleToggleAuthSetting('oidc_registration', !oidcRegistration, setOidcRegistration)} | |
| className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${oidcRegistration ? 'bg-content' : 'bg-edge'}`} | |
| > | |
| <span | |
| className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" | |
| style={{ transform: oidcRegistration ? 'translateX(20px)' : 'translateX(0)' }} | |
| /> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Passkey (WebAuthn) login */} | |
| <div className="bg-white rounded-xl border border-slate-200 overflow-hidden"> | |
| <div className="px-6 py-4 border-b border-slate-100"> | |
| <h2 className="font-semibold text-slate-900">{t('admin.passkey.title')}</h2> | |
| <p className="text-xs text-slate-400 mt-1">{t('admin.passkey.cardHint')}</p> | |
| </div> | |
| <div className="p-6 space-y-5"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-medium text-slate-700">{t('admin.passkey.login')}</p> | |
| <p className="text-xs text-slate-400 mt-0.5">{t('admin.passkey.loginHint')}</p> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={() => handleToggleAuthSetting('passkey_login', !passkeyLogin, setPasskeyLogin)} | |
| className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${passkeyLogin ? 'bg-content' : 'bg-edge'}`} | |
| > | |
| <span | |
| className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" | |
| style={{ transform: passkeyLogin ? 'translateX(20px)' : 'translateX(0)' }} | |
| /> | |
| </button> | |
| </div> | |
| {passkeyLogin && !passkeyConfigured && ( | |
| <p className="flex items-start gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2"> | |
| <AlertTriangle size={14} className="flex-shrink-0 mt-0.5" /> | |
| {t('admin.passkey.notConfigured')} | |
| </p> | |
| )} | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1">{t('admin.passkey.rpId')}</label> | |
| <p className="text-xs text-slate-400 mb-1.5">{t('admin.passkey.rpIdHint')}</p> | |
| <input | |
| type="text" | |
| value={webauthnRpId} | |
| onChange={e => setWebauthnRpId(e.target.value)} | |
| placeholder="trek.example.org" | |
| className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1">{t('admin.passkey.origins')}</label> | |
| <p className="text-xs text-slate-400 mb-1.5">{t('admin.passkey.originsHint')}</p> | |
| <input | |
| type="text" | |
| value={webauthnOrigins} | |
| onChange={e => setWebauthnOrigins(e.target.value)} | |
| placeholder="https://trek.example.org" | |
| className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" | |
| /> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={handleSaveWebauthn} | |
| disabled={savingWebauthn} | |
| className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:opacity-50" | |
| > | |
| {savingWebauthn ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} | |
| {t('common.save')} | |
| </button> | |
| </div> | |
| </div> | |
| {/* Require 2FA for all users */} | |
| <div className="bg-white rounded-xl border border-slate-200 overflow-hidden"> | |
| <div className="px-6 py-4 border-b border-slate-100"> | |
| <h2 className="font-semibold text-slate-900">{t('admin.requireMfa')}</h2> | |
| </div> | |
| <div className="p-6"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-medium text-slate-700">{t('admin.requireMfa')}</p> | |
| <p className="text-xs text-slate-400 mt-0.5">{t('admin.requireMfaHint')}</p> | |
| </div> | |
| <button | |
| type="button" | |
| onClick={() => handleToggleRequireMfa(!requireMfa)} | |
| className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${requireMfa ? 'bg-content' : 'bg-edge'}`} | |
| > | |
| <span | |
| className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" | |
| style={{ transform: requireMfa ? 'translateX(20px)' : 'translateX(0)' }} | |
| /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Allowed File Types */} | |
| <div className="bg-white rounded-xl border border-slate-200 overflow-hidden"> | |
| <div className="px-6 py-4 border-b border-slate-100"> | |
| <h2 className="font-semibold text-slate-900">{t('admin.fileTypes')}</h2> | |
| <p className="text-xs text-slate-400 mt-1">{t('admin.fileTypesHint')}</p> | |
| </div> | |
| <div className="p-6"> | |
| <input | |
| type="text" | |
| value={allowedFileTypes} | |
| onChange={e => setAllowedFileTypes(e.target.value)} | |
| placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv" | |
| className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" | |
| /> | |
| <p className="text-xs text-slate-400 mt-2">{t('admin.fileTypesFormat')}</p> | |
| <button | |
| 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) } | |
| }} | |
| disabled={savingFileTypes} | |
| className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400 mt-3" | |
| > | |
| {savingFileTypes ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />} | |
| {t('common.save')} | |
| </button> | |
| </div> | |
| </div> | |
| {/* API Keys */} | |
| <div className="bg-white rounded-xl border border-slate-200 overflow-hidden"> | |
| <div className="px-6 py-4 border-b border-slate-100"> | |
| <h2 className="font-semibold text-slate-900">{t('admin.apiKeys')}</h2> | |
| <p className="text-xs text-slate-400 mt-1">{t('admin.apiKeysHint')}</p> | |
| </div> | |
| <div className="p-6 space-y-4"> | |
| {/* Google Maps Key */} | |
| <div> | |
| <label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5"> | |
| {t('admin.mapsKey')} | |
| <span className="text-[9px] font-medium px-1.5 py-px rounded-full bg-emerald-200 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200">{t('admin.recommended')}</span> | |
| </label> | |
| <div className="flex gap-2"> | |
| <div className="relative flex-1"> | |
| <input | |
| type={showKeys.maps ? 'text' : 'password'} | |
| value={mapsKey} | |
| onChange={e => setMapsKey(e.target.value)} | |
| placeholder={t('settings.keyPlaceholder')} | |
| className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" | |
| /> | |
| <button | |
| type="button" | |
| onClick={() => toggleKey('maps')} | |
| className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600" | |
| > | |
| {showKeys.maps ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} | |
| </button> | |
| </div> | |
| <button | |
| onClick={() => handleValidateKey('maps')} | |
| disabled={!mapsKey || validating.maps} | |
| className="px-3 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5" | |
| > | |
| {validating.maps ? ( | |
| <Loader2 className="w-4 h-4 animate-spin" /> | |
| ) : validation.maps === true ? ( | |
| <CheckCircle className="w-4 h-4 text-emerald-500" /> | |
| ) : validation.maps === false ? ( | |
| <XCircle className="w-4 h-4 text-red-500" /> | |
| ) : null} | |
| {t('admin.validateKey')} | |
| </button> | |
| </div> | |
| <p className="text-xs text-slate-400 mt-1">{t('admin.mapsKeyHintLong')}</p> | |
| {validation.maps === true && ( | |
| <p className="text-xs text-emerald-600 mt-1 flex items-center gap-1"> | |
| <span className="w-2 h-2 bg-emerald-500 rounded-full inline-block"></span> | |
| {t('admin.keyValid')} | |
| </p> | |
| )} | |
| {validation.maps === false && ( | |
| <p className="text-xs text-red-500 mt-1 flex items-center gap-1"> | |
| <span className="w-2 h-2 bg-red-500 rounded-full inline-block"></span> | |
| {t('admin.keyInvalid')} | |
| </p> | |
| )} | |
| </div> | |
| {/* Place Photos Toggle */} | |
| <div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100"> | |
| <div> | |
| <p className="text-sm font-medium text-slate-700">{t('admin.placesPhotos.title')}</p> | |
| <p className="text-xs text-slate-400 mt-0.5">{t('admin.placesPhotos.subtitle')}</p> | |
| </div> | |
| <button | |
| onClick={async () => { | |
| const next = !placesPhotosEnabled | |
| setPlacesPhotosEnabledState(next) | |
| setPlacesPhotosEnabled(next) | |
| try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) } | |
| }} | |
| className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${placesPhotosEnabled ? 'bg-content' : 'bg-edge'}`} | |
| > | |
| <span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesPhotosEnabled ? 'translateX(20px)' : 'translateX(0)' }} /> | |
| </button> | |
| </div> | |
| {/* Place Autocomplete Toggle */} | |
| <div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100"> | |
| <div> | |
| <p className="text-sm font-medium text-slate-700">{t('admin.placesAutocomplete.title')}</p> | |
| <p className="text-xs text-slate-400 mt-0.5">{t('admin.placesAutocomplete.subtitle')}</p> | |
| </div> | |
| <button | |
| onClick={async () => { | |
| const next = !placesAutocompleteEnabled | |
| setPlacesAutocompleteEnabledState(next) | |
| setPlacesAutocompleteEnabled(next) | |
| try { await adminApi.updatePlacesAutocomplete(next) } catch { setPlacesAutocompleteEnabledState(!next); setPlacesAutocompleteEnabled(!next) } | |
| }} | |
| className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${placesAutocompleteEnabled ? 'bg-content' : 'bg-edge'}`} | |
| > | |
| <span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesAutocompleteEnabled ? 'translateX(20px)' : 'translateX(0)' }} /> | |
| </button> | |
| </div> | |
| {/* Place Details Toggle */} | |
| <div className="flex items-center justify-between gap-4 py-3 border-t border-slate-100"> | |
| <div> | |
| <p className="text-sm font-medium text-slate-700">{t('admin.placesDetails.title')}</p> | |
| <p className="text-xs text-slate-400 mt-0.5">{t('admin.placesDetails.subtitle')}</p> | |
| </div> | |
| <button | |
| onClick={async () => { | |
| const next = !placesDetailsEnabled | |
| setPlacesDetailsEnabledState(next) | |
| setPlacesDetailsEnabled(next) | |
| try { await adminApi.updatePlacesDetails(next) } catch { setPlacesDetailsEnabledState(!next); setPlacesDetailsEnabled(!next) } | |
| }} | |
| className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${placesDetailsEnabled ? 'bg-content' : 'bg-edge'}`} | |
| > | |
| <span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200" style={{ transform: placesDetailsEnabled ? 'translateX(20px)' : 'translateX(0)' }} /> | |
| </button> | |
| </div> | |
| {/* Open-Meteo Weather Info */} | |
| <div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden"> | |
| <div className="px-4 py-3 flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center flex-shrink-0"> | |
| <Sun className="w-3.5 h-3.5 text-white" /> | |
| </div> | |
| <span className="text-sm font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.title')}</span> | |
| </div> | |
| <span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-emerald-200 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200">{t('admin.weather.badge')}</span> | |
| </div> | |
| <div className="px-4 pb-3"> | |
| <p className="text-xs text-emerald-800 dark:text-emerald-300 leading-relaxed">{t('admin.weather.description')}</p> | |
| <p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-1.5 leading-relaxed">{t('admin.weather.locationHint')}</p> | |
| <div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2"> | |
| <div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800"> | |
| <p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.forecast')}</p> | |
| <p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.forecastDesc')}</p> | |
| </div> | |
| <div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800"> | |
| <p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.climate')}</p> | |
| <p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.climateDesc')}</p> | |
| </div> | |
| <div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800"> | |
| <p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.requests')}</p> | |
| <p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.requestsDesc')}</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <button | |
| onClick={handleSaveApiKeys} | |
| disabled={savingKeys} | |
| className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400" | |
| > | |
| {savingKeys ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />} | |
| {t('common.save')} | |
| </button> | |
| </div> | |
| </div> | |
| {/* OIDC / SSO Configuration */} | |
| <div className="bg-white rounded-xl border border-slate-200 overflow-hidden"> | |
| <div className="px-6 py-4 border-b border-slate-100"> | |
| <h2 className="font-semibold text-slate-900">{t('admin.oidcTitle')}</h2> | |
| <p className="text-xs text-slate-400 mt-1">{t('admin.oidcSubtitle')}</p> | |
| </div> | |
| <div className="p-6 space-y-4"> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.oidcDisplayName')}</label> | |
| <input | |
| type="text" | |
| value={oidcConfig.display_name} | |
| onChange={e => setOidcConfig(c => ({ ...c, display_name: e.target.value }))} | |
| placeholder='z.B. Google, Authentik, Keycloak' | |
| className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.oidcIssuer')}</label> | |
| <input | |
| type="url" | |
| value={oidcConfig.issuer} | |
| onChange={e => setOidcConfig(c => ({ ...c, issuer: e.target.value }))} | |
| placeholder='https://accounts.google.com' | |
| className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" | |
| /> | |
| <p className="text-xs text-slate-400 mt-1">{t('admin.oidcIssuerHint')}</p> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1.5">Discovery URL <span className="text-slate-400 font-normal">(optional)</span></label> | |
| <input | |
| 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="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" | |
| /> | |
| <p className="text-xs text-slate-400 mt-1">Override the auto-constructed discovery URL. Required for providers like Authentik where the endpoint is not at <code className="bg-slate-100 px-1 rounded">{'<issuer>/.well-known/openid-configuration'}</code>.</p> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1.5">Client ID</label> | |
| <input | |
| type="text" | |
| value={oidcConfig.client_id} | |
| onChange={e => setOidcConfig(c => ({ ...c, client_id: e.target.value }))} | |
| className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-slate-700 mb-1.5">Client Secret</label> | |
| <input | |
| type="password" | |
| value={oidcConfig.client_secret} | |
| onChange={e => setOidcConfig(c => ({ ...c, client_secret: e.target.value }))} | |
| placeholder={oidcConfig.client_secret_set ? '••••••••' : ''} | |
| className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" | |
| /> | |
| </div> | |
| <button | |
| 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) | |
| } | |
| }} | |
| disabled={savingOidc} | |
| className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400" | |
| > | |
| {savingOidc ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />} | |
| {t('common.save')} | |
| </button> | |
| </div> | |
| </div> | |
| {/* Danger Zone */} | |
| <div className="bg-white rounded-xl border border-red-200 overflow-hidden"> | |
| <div className="px-6 py-4 border-b border-red-100 bg-red-50"> | |
| <h2 className="font-semibold text-red-700 flex items-center gap-2"> | |
| <AlertTriangle className="w-4 h-4" /> | |
| Danger Zone | |
| </h2> | |
| </div> | |
| <div className="p-6"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <p className="text-sm font-medium text-slate-700">Rotate JWT Secret</p> | |
| <p className="text-xs text-slate-400 mt-0.5">Generate a new JWT signing secret. All active sessions will be invalidated immediately.</p> | |
| </div> | |
| <button | |
| onClick={() => setShowRotateJwtModal(true)} | |
| className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors" | |
| > | |
| <RefreshCw className="w-4 h-4" /> | |
| Rotate | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |