mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
v2.4.0 — OIDC login, OpenStreetMap search, account management
Features: - Single Sign-On (OIDC) — login with Google, Apple, Authentik, Keycloak - OpenStreetMap place search as free fallback when no Google API key - Change password in user settings - Delete own account (with last-admin protection) - Last login column in admin user management - SSO badge and provider info in user settings - Google API key "Recommended" badge in admin panel Improvements: - API keys load correctly after page reload - Validate auto-saves keys before testing - Time format respects 12h/24h setting everywhere - Dark mode fixes for popups and backup buttons - Admin stats: removed photos, 4-column layout - Profile picture upload button on avatar overlay - TravelStats duplicate key fix - Backup panel dark mode support
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { adminApi, authApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import Modal from '../components/shared/Modal'
|
||||
@@ -13,7 +14,8 @@ import CustomSelect from '../components/shared/CustomSelect'
|
||||
|
||||
export default function AdminPage() {
|
||||
const { demoMode } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const TABS = [
|
||||
{ id: 'users', label: t('admin.tabs.users') },
|
||||
{ id: 'categories', label: t('admin.tabs.categories') },
|
||||
@@ -30,6 +32,10 @@ export default function AdminPage() {
|
||||
const [showCreateUser, setShowCreateUser] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', role: 'user' })
|
||||
|
||||
// OIDC config
|
||||
const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', display_name: '' })
|
||||
const [savingOidc, setSavingOidc] = useState(false)
|
||||
|
||||
// Registration toggle
|
||||
const [allowRegistration, setAllowRegistration] = useState(true)
|
||||
|
||||
@@ -49,6 +55,7 @@ export default function AdminPage() {
|
||||
loadData()
|
||||
loadAppConfig()
|
||||
loadApiKeys()
|
||||
adminApi.getOidc().then(setOidcConfig).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
@@ -238,12 +245,11 @@ export default function AdminPage() {
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{ label: t('admin.stats.users'), value: stats.totalUsers, icon: Users },
|
||||
{ label: t('admin.stats.trips'), value: stats.totalTrips, icon: Briefcase },
|
||||
{ label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map },
|
||||
{ label: t('admin.stats.photos'), value: stats.totalPhotos || 0, icon: Camera },
|
||||
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
|
||||
].map(({ label, value, icon: Icon }) => (
|
||||
<div key={label} className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
@@ -303,6 +309,7 @@ export default function AdminPage() {
|
||||
<th className="px-5 py-3">{t('admin.table.email')}</th>
|
||||
<th className="px-5 py-3">{t('admin.table.role')}</th>
|
||||
<th className="px-5 py-3">{t('admin.table.created')}</th>
|
||||
<th className="px-5 py-3">{t('admin.table.lastLogin')}</th>
|
||||
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -334,7 +341,10 @@ export default function AdminPage() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-500">
|
||||
{new Date(u.created_at).toLocaleDateString('de-DE')}
|
||||
{new Date(u.created_at).toLocaleDateString(locale)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-sm text-slate-500">
|
||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
@@ -403,7 +413,10 @@ export default function AdminPage() {
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Google Maps Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.mapsKey')}</label>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('admin.mapsKey')}
|
||||
<span style={{ fontSize: 10, fontWeight: 500, padding: '1px 7px', borderRadius: 99, background: '#dbeafe', color: '#1d4ed8' }}>{t('admin.recommended')}</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
@@ -436,7 +449,7 @@ export default function AdminPage() {
|
||||
{t('admin.validateKey')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.mapsKeyHint')}</p>
|
||||
<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>
|
||||
@@ -511,6 +524,73 @@ export default function AdminPage() {
|
||||
</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-0.5">{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">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 }))}
|
||||
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 {
|
||||
await adminApi.updateOidc(oidcConfig)
|
||||
toast.success(t('admin.oidcSaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user