feat: Refactor admin settings and user management
- Removed Admin Panel from SettingsPage and adjusted related logic. - Added EmailSettings component for SMTP configuration and testing. - Created admin navigation structure in adminNav.js for better organization. - Implemented AdminShell layout for consistent admin UI. - Added RequireAdmin component to protect admin routes. - Developed AdminHomePage for admin dashboard with navigation links. - Created AdminSystemPage for SMTP settings and placeholder metadata export. - Implemented AdminUsersPage for user management, including profile creation and editing.
This commit is contained in:
parent
190c0dd7fa
commit
bbc59457ac
|
|
@ -34,6 +34,11 @@ import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
|
|||
import AdminPromptsPage from './pages/AdminPromptsPage'
|
||||
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
|
||||
import AdminFocusAreasPage from './pages/AdminFocusAreasPage'
|
||||
import AdminHomePage from './pages/AdminHomePage'
|
||||
import AdminUsersPage from './pages/AdminUsersPage'
|
||||
import AdminSystemPage from './pages/AdminSystemPage'
|
||||
import RequireAdmin from './layouts/RequireAdmin'
|
||||
import AdminShell from './layouts/AdminShell'
|
||||
import SubscriptionPage from './pages/SubscriptionPage'
|
||||
import SleepPage from './pages/SleepPage'
|
||||
import RestDaysPage from './pages/RestDaysPage'
|
||||
|
|
@ -219,17 +224,24 @@ function AppShell() {
|
|||
<Route path="/goals" element={<GoalsPage/>}/>
|
||||
<Route path="/analysis" element={<Analysis/>}/>
|
||||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
<Route path="/admin/tier-limits" element={<AdminTierLimitsPage/>}/>
|
||||
<Route path="/admin/features" element={<AdminFeaturesPage/>}/>
|
||||
<Route path="/admin/tiers" element={<AdminTiersPage/>}/>
|
||||
<Route path="/admin/coupons" element={<AdminCouponsPage/>}/>
|
||||
<Route path="/admin/user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
||||
<Route path="/admin/training-types" element={<AdminTrainingTypesPage/>}/>
|
||||
<Route path="/admin/activity-mappings" element={<AdminActivityMappingsPage/>}/>
|
||||
<Route path="/admin/training-profiles" element={<AdminTrainingProfiles/>}/>
|
||||
<Route path="/admin/prompts" element={<AdminPromptsPage/>}/>
|
||||
<Route path="/admin/goal-types" element={<AdminGoalTypesPage/>}/>
|
||||
<Route path="/admin/focus-areas" element={<AdminFocusAreasPage/>}/>
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route path="admin" element={<AdminShell />}>
|
||||
<Route index element={<AdminHomePage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="system" element={<AdminSystemPage />} />
|
||||
<Route path="tier-limits" element={<AdminTierLimitsPage/>}/>
|
||||
<Route path="features" element={<AdminFeaturesPage/>}/>
|
||||
<Route path="tiers" element={<AdminTiersPage/>}/>
|
||||
<Route path="coupons" element={<AdminCouponsPage/>}/>
|
||||
<Route path="user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
||||
<Route path="training-types" element={<AdminTrainingTypesPage/>}/>
|
||||
<Route path="activity-mappings" element={<AdminActivityMappingsPage/>}/>
|
||||
<Route path="training-profiles" element={<AdminTrainingProfiles/>}/>
|
||||
<Route path="prompts" element={<AdminPromptsPage/>}/>
|
||||
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>
|
||||
<Route path="focus-areas" element={<AdminFocusAreasPage/>}/>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -474,6 +474,136 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
|||
}
|
||||
}
|
||||
|
||||
/* Admin: gruppierte Sub-Navigation (Mobil = Chips pro Gruppe, Desktop = linke Spalte) */
|
||||
.admin-shell {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-shell__layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-shell__nav-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-shell__nav-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-shell__group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.admin-shell__group-chips {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.admin-shell__group-chips::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-shell__nav-item {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7px 12px;
|
||||
border-radius: 20px;
|
||||
border: 1.5px solid var(--border2);
|
||||
background: var(--surface);
|
||||
color: var(--text2);
|
||||
font-family: var(--font);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.admin-shell__nav-item:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.admin-shell__nav-item--active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.admin-shell__nav-item--active:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.admin-shell__main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-page {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.admin-page {
|
||||
max-width: var(--capture-content-max);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.admin-shell__layout {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.admin-shell__nav-wrap {
|
||||
flex: 0 0 260px;
|
||||
max-width: 280px;
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.admin-shell__group-chips {
|
||||
flex-direction: column;
|
||||
overflow-x: visible;
|
||||
overflow-y: visible;
|
||||
padding-bottom: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-shell__nav-item {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
border-radius: 10px;
|
||||
white-space: normal;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.admin-shell__main {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.muted { color: var(--text3); font-size: 13px; }
|
||||
.empty-state { text-align: center; padding: 48px 16px; color: var(--text3); }
|
||||
.empty-state h3 { font-size: 16px; color: var(--text2); margin-bottom: 6px; }
|
||||
|
|
|
|||
78
frontend/src/components/EmailSettings.jsx
Normal file
78
frontend/src/components/EmailSettings.jsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function EmailSettings() {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [testTo, setTestTo] = useState('')
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testMsg, setTestMsg] = useState(null)
|
||||
|
||||
useEffect(()=>{
|
||||
const token = localStorage.getItem('bodytrack_token')||''
|
||||
fetch('/api/admin/email/status',{headers:{'X-Auth-Token':token}})
|
||||
.then(r=>r.json()).then(setStatus)
|
||||
},[])
|
||||
|
||||
const sendTest = async () => {
|
||||
if (!testTo) return
|
||||
setTesting(true); setTestMsg(null)
|
||||
try {
|
||||
const token = localStorage.getItem('bodytrack_token')||''
|
||||
const r = await fetch('/api/admin/email/test',{
|
||||
method:'POST',headers:{'Content-Type':'application/json','X-Auth-Token':token},
|
||||
body:JSON.stringify({to:testTo})
|
||||
})
|
||||
if(!r.ok) throw new Error((await r.json()).detail)
|
||||
setTestMsg('✓ Test-E-Mail gesendet!')
|
||||
} catch(e){ setTestMsg('✗ Fehler: '+e.message) }
|
||||
finally{ setTesting(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card section-gap" style={{marginTop:0}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:10,display:'flex',alignItems:'center',gap:6}}>
|
||||
📧 E-Mail Konfiguration (SMTP)
|
||||
</div>
|
||||
{!status ? <div className="spinner" style={{width:16,height:16}}/> : (
|
||||
<>
|
||||
<div style={{padding:'8px 12px',borderRadius:8,marginBottom:12,
|
||||
background:status.configured?'var(--accent-light)':'var(--warn-bg)',
|
||||
fontSize:12,color:status.configured?'var(--accent-dark)':'var(--warn-text)'}}>
|
||||
{status.configured
|
||||
? <>✓ Konfiguriert: <strong>{status.smtp_user}</strong> via {status.smtp_host}</>
|
||||
: <>⚠️ Nicht konfiguriert. SMTP-Einstellungen in der <code>.env</code> Datei setzen.</>}
|
||||
</div>
|
||||
{status.configured && (
|
||||
<>
|
||||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:10,lineHeight:1.5}}>
|
||||
<strong>App-URL:</strong> {status.app_url}<br/>
|
||||
<span style={{fontSize:10}}>Für korrekte Links in E-Mails (z.B. Recovery-Links). In .env als APP_URL setzen.</span>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8}}>
|
||||
<input type="email" className="form-input" placeholder="test@beispiel.de"
|
||||
value={testTo} onChange={e=>setTestTo(e.target.value)} style={{flex:1}}/>
|
||||
<button className="btn btn-secondary" onClick={sendTest} disabled={testing}>
|
||||
{testing?'…':'Test'}
|
||||
</button>
|
||||
</div>
|
||||
{testMsg && <div style={{fontSize:12,marginTop:6,
|
||||
color:testMsg.startsWith('✓')?'var(--accent)':'#D85A30'}}>{testMsg}</div>}
|
||||
</>
|
||||
)}
|
||||
{!status.configured && (
|
||||
<div style={{fontSize:11,color:'var(--text3)',lineHeight:1.6}}>
|
||||
Füge folgende Zeilen zur <code>.env</code> Datei hinzu:<br/>
|
||||
<code style={{background:'var(--surface2)',padding:'6px 8px',borderRadius:4,
|
||||
display:'block',marginTop:6,fontSize:11}}>
|
||||
SMTP_HOST=smtp.gmail.com<br/>
|
||||
SMTP_PORT=587<br/>
|
||||
SMTP_USER=deine@gmail.com<br/>
|
||||
SMTP_PASS=dein_app_passwort<br/>
|
||||
APP_URL=http://192.168.2.49:3002
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
frontend/src/config/adminNav.js
Normal file
61
frontend/src/config/adminNav.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Gruppierte Admin-Navigation (Shell: Chips mobil, Seitenleiste Desktop).
|
||||
* @typedef {{ to: string, label: string, end?: boolean }} AdminNavItem
|
||||
* @typedef {{ id: string, label: string, items: AdminNavItem[] }} AdminNavGroup
|
||||
*/
|
||||
|
||||
/** @type {AdminNavGroup[]} */
|
||||
export const ADMIN_NAV_GROUPS = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Übersicht',
|
||||
items: [{ to: '/admin', label: 'Start', end: true }],
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'Benutzerverwaltung',
|
||||
items: [{ to: '/admin/users', label: 'Profile & Rollen' }],
|
||||
},
|
||||
{
|
||||
id: 'features',
|
||||
label: 'Features',
|
||||
items: [
|
||||
{ to: '/admin/features', label: 'Feature-Registry' },
|
||||
{ to: '/admin/tier-limits', label: 'Tier-Limits' },
|
||||
{ to: '/admin/user-restrictions', label: 'User-Overrides' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'subscription',
|
||||
label: 'Subscription',
|
||||
items: [
|
||||
{ to: '/admin/tiers', label: 'Tiers' },
|
||||
{ to: '/admin/coupons', label: 'Coupons' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'training',
|
||||
label: 'Trainingstypen',
|
||||
items: [
|
||||
{ to: '/admin/training-types', label: 'Trainingstypen' },
|
||||
{ to: '/admin/activity-mappings', label: 'Activity-Mappings' },
|
||||
{ to: '/admin/training-profiles', label: 'Trainings-Profile' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'goals',
|
||||
label: 'Ziele & Fokus',
|
||||
items: [
|
||||
{ to: '/admin/goal-types', label: 'Ziel-Typen' },
|
||||
{ to: '/admin/focus-areas', label: 'Focus Areas' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: 'Basiseinstellungen',
|
||||
items: [
|
||||
{ to: '/admin/prompts', label: 'KI-Prompts' },
|
||||
{ to: '/admin/system', label: 'SMTP & Metadaten-Export' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
@ -37,7 +37,7 @@ export function getMainNavItems(isAdmin) {
|
|||
Icon: icons[i]
|
||||
}))
|
||||
if (isAdmin) {
|
||||
raw.push({ to: '/admin/features', label: 'Admin', Icon: Shield })
|
||||
raw.push({ to: '/admin', label: 'Admin', end: false, Icon: Shield })
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
|
|
|||
40
frontend/src/layouts/AdminShell.jsx
Normal file
40
frontend/src/layouts/AdminShell.jsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Outlet, NavLink } from 'react-router-dom'
|
||||
import { ADMIN_NAV_GROUPS } from '../config/adminNav'
|
||||
|
||||
export default function AdminShell() {
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<div className="admin-shell__layout">
|
||||
<nav className="admin-shell__nav-wrap" aria-label="Adminbereich">
|
||||
<div className="admin-shell__nav-groups">
|
||||
{ADMIN_NAV_GROUPS.map((group) => (
|
||||
<div key={group.id} className="admin-shell__nav-group">
|
||||
<div className="admin-shell__group-title">{group.label}</div>
|
||||
<div className="admin-shell__group-chips">
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={`${group.id}-${item.to}`}
|
||||
to={item.to}
|
||||
end={!!item.end}
|
||||
className={({ isActive }) =>
|
||||
'admin-shell__nav-item' +
|
||||
(isActive ? ' admin-shell__nav-item--active' : '')
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
<div className="admin-shell__main">
|
||||
<div className="admin-page">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
frontend/src/layouts/RequireAdmin.jsx
Normal file
11
frontend/src/layouts/RequireAdmin.jsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function RequireAdmin() {
|
||||
const { isAdmin } = useAuth()
|
||||
const loc = useLocation()
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/" replace state={{ from: loc.pathname, adminDenied: true }} />
|
||||
}
|
||||
return <Outlet />
|
||||
}
|
||||
40
frontend/src/pages/AdminHomePage.jsx
Normal file
40
frontend/src/pages/AdminHomePage.jsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { Shield } from 'lucide-react'
|
||||
import { ADMIN_NAV_GROUPS } from '../config/adminNav'
|
||||
|
||||
const LINK_GROUPS = ADMIN_NAV_GROUPS.filter((g) => g.id !== 'overview')
|
||||
|
||||
export default function AdminHomePage() {
|
||||
return (
|
||||
<div>
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:12}}>
|
||||
<Shield size={20} color="var(--accent)"/>
|
||||
<h1 style={{fontSize:18,fontWeight:700,margin:0}}>Adminbereich</h1>
|
||||
</div>
|
||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:20,lineHeight:1.6}}>
|
||||
Wähle links (Desktop) oder oben (Mobil) einen Bereich – oder springe direkt zu einer Gruppe:
|
||||
</p>
|
||||
<div style={{display:'flex',flexDirection:'column',gap:16}}>
|
||||
{LINK_GROUPS.map((group) => (
|
||||
<div key={group.id} className="card section-gap" style={{margin:0}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:10,color:'var(--text1)'}}>
|
||||
{group.label}
|
||||
</div>
|
||||
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
||||
{group.items.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="btn btn-secondary btn-full"
|
||||
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
frontend/src/pages/AdminSystemPage.jsx
Normal file
65
frontend/src/pages/AdminSystemPage.jsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Settings } from 'lucide-react'
|
||||
import EmailSettings from '../components/EmailSettings'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
export default function AdminSystemPage() {
|
||||
return (
|
||||
<div>
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:16}}>
|
||||
<Settings size={18} color="var(--accent)"/>
|
||||
<h2 style={{fontSize:17,fontWeight:700,margin:0}}>Basiseinstellungen</h2>
|
||||
</div>
|
||||
<p style={{fontSize:13,color:'var(--text2)',marginBottom:16,lineHeight:1.6}}>
|
||||
SMTP für System-E-Mails und Export der Placeholder-Metadaten für Dokumentation und Compliance.
|
||||
</p>
|
||||
|
||||
<EmailSettings />
|
||||
|
||||
<div className="card section-gap" style={{marginTop:16}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
|
||||
<Settings size={16} color="var(--accent)"/> Placeholder-Metadaten (Export)
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||
Vollständige Metadaten aller registrierten Platzhalter (JSON/ZIP für Katalog und Reports).
|
||||
</div>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<button type="button" className="btn btn-secondary btn-full"
|
||||
onClick={async()=>{
|
||||
try {
|
||||
const data = await api.exportPlaceholdersExtendedJson()
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'})
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `placeholder-metadata-extended-${new Date().toISOString().split('T')[0]}.json`
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch(e) {
|
||||
alert('Fehler beim Export: '+e.message)
|
||||
}
|
||||
}}>
|
||||
📄 Complete JSON exportieren
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary btn-full"
|
||||
onClick={()=>{
|
||||
try {
|
||||
const token = localStorage.getItem('bodytrack_token')
|
||||
const a = document.createElement('a')
|
||||
a.href = `/api/prompts/placeholders/export-catalog-zip?token=${token}`
|
||||
a.download = `placeholder-catalog-${new Date().toISOString().split('T')[0]}.zip`
|
||||
a.click()
|
||||
} catch(e) {
|
||||
alert('Fehler beim Export: '+e.message)
|
||||
}
|
||||
}}>
|
||||
📦 Complete ZIP (JSON + Markdown + Reports)
|
||||
</button>
|
||||
</div>
|
||||
<div style={{fontSize:11,color:'var(--text3)',marginTop:8,lineHeight:1.5}}>
|
||||
<strong>JSON:</strong> Maschinenlesbare Metadaten ·{' '}
|
||||
<strong>ZIP:</strong> Katalog, Gap Report, Export Spec
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Trash2, Pencil, Check, X, Shield, Key, Settings } from 'lucide-react'
|
||||
import { Plus, Trash2, Pencil, Check, X, Shield, Key } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { api } from '../utils/api'
|
||||
|
|
@ -17,23 +17,6 @@ function Avatar({ profile, size=36 }) {
|
|||
)
|
||||
}
|
||||
|
||||
function Toggle({ value, onChange, label, disabled=false }) {
|
||||
return (
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',
|
||||
padding:'8px 0',borderBottom:'1px solid var(--border)'}}>
|
||||
<span style={{fontSize:13,color:disabled?'var(--text3)':'var(--text1)'}}>{label}</span>
|
||||
<div onClick={()=>!disabled&&onChange(!value)}
|
||||
style={{width:40,height:22,borderRadius:11,background:value?'var(--accent)':'var(--border)',
|
||||
position:'relative',cursor:disabled?'not-allowed':'pointer',transition:'background 0.2s',
|
||||
opacity:disabled?0.5:1}}>
|
||||
<div style={{position:'absolute',top:2,left:value?18:2,width:18,height:18,
|
||||
borderRadius:'50%',background:'white',transition:'left 0.2s',
|
||||
boxShadow:'0 1px 3px rgba(0,0,0,0.2)'}}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NewProfileForm({ onSave, onCancel }) {
|
||||
const [form, setForm] = useState({
|
||||
name:'', pin:'', email:'', avatar_color:COLORS[0],
|
||||
|
|
@ -210,7 +193,6 @@ function ProfileCard({ profile, currentId, onRefresh }) {
|
|||
|
||||
{expanded && (
|
||||
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
|
||||
{/* Permissions */}
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BERECHTIGUNGEN</div>
|
||||
|
||||
<div style={{marginBottom:8}}>
|
||||
|
|
@ -231,7 +213,6 @@ function ProfileCard({ profile, currentId, onRefresh }) {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Feature-Overrides */}
|
||||
<div style={{marginBottom:12,padding:10,background:'var(--accent-light)',borderRadius:6,fontSize:12}}>
|
||||
<strong>Feature-Limits:</strong> Nutze die neue{' '}
|
||||
<Link to="/admin/user-restrictions" style={{color:'var(--accent-dark)',fontWeight:600}}>
|
||||
|
|
@ -240,13 +221,11 @@ function ProfileCard({ profile, currentId, onRefresh }) {
|
|||
Seite um individuelle Limits zu setzen.
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:6}}>E-MAIL (für Recovery & Zusammenfassungen)</div>
|
||||
<EmailEditor profileId={profile.id} currentEmail={profile.email} onSaved={onRefresh}/>
|
||||
</div>
|
||||
|
||||
{/* PIN change */}
|
||||
<div style={{marginTop:14,paddingTop:12,borderTop:'1px solid var(--border)'}}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8,display:'flex',alignItems:'center',gap:4}}>
|
||||
<Key size={12}/> PIN / PASSWORT ÄNDERN
|
||||
|
|
@ -264,84 +243,7 @@ function ProfileCard({ profile, currentId, onRefresh }) {
|
|||
)
|
||||
}
|
||||
|
||||
function EmailSettings() {
|
||||
const [status, setStatus] = useState(null)
|
||||
const [testTo, setTestTo] = useState('')
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testMsg, setTestMsg] = useState(null)
|
||||
|
||||
useEffect(()=>{
|
||||
const token = localStorage.getItem('bodytrack_token')||''
|
||||
fetch('/api/admin/email/status',{headers:{'X-Auth-Token':token}})
|
||||
.then(r=>r.json()).then(setStatus)
|
||||
},[])
|
||||
|
||||
const sendTest = async () => {
|
||||
if (!testTo) return
|
||||
setTesting(true); setTestMsg(null)
|
||||
try {
|
||||
const token = localStorage.getItem('bodytrack_token')||''
|
||||
const r = await fetch('/api/admin/email/test',{
|
||||
method:'POST',headers:{'Content-Type':'application/json','X-Auth-Token':token},
|
||||
body:JSON.stringify({to:testTo})
|
||||
})
|
||||
if(!r.ok) throw new Error((await r.json()).detail)
|
||||
setTestMsg('✓ Test-E-Mail gesendet!')
|
||||
} catch(e){ setTestMsg('✗ Fehler: '+e.message) }
|
||||
finally{ setTesting(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card section-gap" style={{marginTop:16}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:10,display:'flex',alignItems:'center',gap:6}}>
|
||||
📧 E-Mail Konfiguration
|
||||
</div>
|
||||
{!status ? <div className="spinner" style={{width:16,height:16}}/> : (
|
||||
<>
|
||||
<div style={{padding:'8px 12px',borderRadius:8,marginBottom:12,
|
||||
background:status.configured?'var(--accent-light)':'var(--warn-bg)',
|
||||
fontSize:12,color:status.configured?'var(--accent-dark)':'var(--warn-text)'}}>
|
||||
{status.configured
|
||||
? <>✓ Konfiguriert: <strong>{status.smtp_user}</strong> via {status.smtp_host}</>
|
||||
: <>⚠️ Nicht konfiguriert. SMTP-Einstellungen in der <code>.env</code> Datei setzen.</>}
|
||||
</div>
|
||||
{status.configured && (
|
||||
<>
|
||||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:10,lineHeight:1.5}}>
|
||||
<strong>App-URL:</strong> {status.app_url}<br/>
|
||||
<span style={{fontSize:10}}>Für korrekte Links in E-Mails (z.B. Recovery-Links). In .env als APP_URL setzen.</span>
|
||||
</div>
|
||||
<div style={{display:'flex',gap:8}}>
|
||||
<input type="email" className="form-input" placeholder="test@beispiel.de"
|
||||
value={testTo} onChange={e=>setTestTo(e.target.value)} style={{flex:1}}/>
|
||||
<button className="btn btn-secondary" onClick={sendTest} disabled={testing}>
|
||||
{testing?'…':'Test'}
|
||||
</button>
|
||||
</div>
|
||||
{testMsg && <div style={{fontSize:12,marginTop:6,
|
||||
color:testMsg.startsWith('✓')?'var(--accent)':'#D85A30'}}>{testMsg}</div>}
|
||||
</>
|
||||
)}
|
||||
{!status.configured && (
|
||||
<div style={{fontSize:11,color:'var(--text3)',lineHeight:1.6}}>
|
||||
Füge folgende Zeilen zur <code>.env</code> Datei hinzu:<br/>
|
||||
<code style={{background:'var(--surface2)',padding:'6px 8px',borderRadius:4,
|
||||
display:'block',marginTop:6,fontSize:11}}>
|
||||
SMTP_HOST=smtp.gmail.com<br/>
|
||||
SMTP_PORT=587<br/>
|
||||
SMTP_USER=deine@gmail.com<br/>
|
||||
SMTP_PASS=dein_app_passwort<br/>
|
||||
APP_URL=http://192.168.2.49:3002
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPanel() {
|
||||
export default function AdminUsersPage() {
|
||||
const { session } = useAuth()
|
||||
const [profiles, setProfiles] = useState([])
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
|
@ -367,7 +269,7 @@ export default function AdminPanel() {
|
|||
|
||||
<div style={{padding:'10px 12px',background:'var(--accent-light)',borderRadius:8,
|
||||
fontSize:12,color:'var(--accent-dark)',marginBottom:16,lineHeight:1.5}}>
|
||||
👑 Du bist Admin. Hier kannst du Profile verwalten, Berechtigungen setzen und KI-Limits konfigurieren.
|
||||
👑 Profile anlegen, Rollen setzen und Recovery-E-Mail pro Nutzer pflegen. Feature-Limits über „User-Overrides“ in der Seitenleiste.
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
|
|
@ -384,171 +286,6 @@ export default function AdminPanel() {
|
|||
<Plus size={14}/> Neues Profil anlegen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Email Settings */}
|
||||
<EmailSettings/>
|
||||
|
||||
{/* v9c Subscription Management */}
|
||||
<div className="card section-gap" style={{marginTop:16}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
|
||||
<Settings size={16} color="var(--accent)"/> Subscription-System (v9c)
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||
Verwalte Tiers, Features und Limits für das neue Freemium-System.
|
||||
</div>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<Link to="/admin/tiers">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🎯 Tiers verwalten
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/admin/features">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🔧 Feature-Registry verwalten
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/admin/tier-limits">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
📊 Tier Limits Matrix bearbeiten
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/admin/coupons">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🎟️ Coupons verwalten
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/admin/user-restrictions">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
👤 User Feature-Overrides
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v9d Training Types Management */}
|
||||
<div className="card section-gap" style={{marginTop:16}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
|
||||
<Settings size={16} color="var(--accent)"/> Trainingstypen (v9d)
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||
Verwalte Trainingstypen, Kategorien und Activity-Mappings (lernendes System).
|
||||
</div>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<Link to="/admin/training-types">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🏋️ Trainingstypen verwalten
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/admin/activity-mappings">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🔗 Activity-Mappings (lernendes System)
|
||||
</button>
|
||||
</Link>
|
||||
<Link to="/admin/training-profiles">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
⭐ Training Type Profiles (#15)
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KI-Prompts Section */}
|
||||
<div className="card section-gap" style={{marginTop:16}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
|
||||
<Settings size={16} color="var(--accent)"/> KI-Prompts (v9f)
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||
Verwalte AI-Prompts mit KI-Unterstützung: Generiere, optimiere und organisiere Prompts.
|
||||
</div>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<Link to="/admin/prompts">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🤖 KI-Prompts verwalten
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Goal Types Section */}
|
||||
<div className="card section-gap" style={{marginTop:16}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
|
||||
<Settings size={16} color="var(--accent)"/> Ziel-Typen (v9e)
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||
Verwalte Goal-Type-Definitionen: Erstelle custom goal types mit oder ohne automatische Datenquelle.
|
||||
</div>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<Link to="/admin/goal-types">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🎯 Ziel-Typen verwalten
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Focus Areas Section */}
|
||||
<div className="card section-gap" style={{marginTop:16}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
|
||||
<Settings size={16} color="var(--accent)"/> Focus Areas (v9g)
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||
Verwalte Focus Area Definitionen: Dynamisches, erweiterbares System mit 26+ Bereichen über 7 Kategorien.
|
||||
</div>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<Link to="/admin/focus-areas">
|
||||
<button className="btn btn-secondary btn-full">
|
||||
🎯 Focus Areas verwalten
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder Metadata Export Section */}
|
||||
<div className="card section-gap" style={{marginTop:16}}>
|
||||
<div style={{fontWeight:700,fontSize:14,marginBottom:12,display:'flex',alignItems:'center',gap:6}}>
|
||||
<Settings size={16} color="var(--accent)"/> Placeholder Metadata Export (v1.0)
|
||||
</div>
|
||||
<div style={{fontSize:12,color:'var(--text3)',marginBottom:12,lineHeight:1.5}}>
|
||||
Exportiere vollständige Metadaten aller 116 Placeholders. Normative Compliance v1.0.0.
|
||||
</div>
|
||||
<div style={{display:'grid',gap:8}}>
|
||||
<button className="btn btn-secondary btn-full"
|
||||
onClick={async()=>{
|
||||
try {
|
||||
const data = await api.exportPlaceholdersExtendedJson()
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'})
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `placeholder-metadata-extended-${new Date().toISOString().split('T')[0]}.json`
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch(e) {
|
||||
alert('Fehler beim Export: '+e.message)
|
||||
}
|
||||
}}>
|
||||
📄 Complete JSON exportieren
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-full"
|
||||
onClick={async()=>{
|
||||
try {
|
||||
const token = localStorage.getItem('bodytrack_token')
|
||||
const a = document.createElement('a')
|
||||
a.href = `/api/prompts/placeholders/export-catalog-zip?token=${token}`
|
||||
a.download = `placeholder-catalog-${new Date().toISOString().split('T')[0]}.zip`
|
||||
a.click()
|
||||
} catch(e) {
|
||||
alert('Fehler beim Export: '+e.message)
|
||||
}
|
||||
}}>
|
||||
📦 Complete ZIP (JSON + Markdown + Reports)
|
||||
</button>
|
||||
</div>
|
||||
<div style={{fontSize:11,color:'var(--text3)',marginTop:8,lineHeight:1.5}}>
|
||||
<strong>JSON:</strong> Maschinenlesbare Metadaten aller Placeholders<br/>
|
||||
<strong>ZIP:</strong> Katalog (JSON + MD), Gap Report, Export Spec (4 Dateien)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key, BarChart3 } from 'lucide-react'
|
||||
import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Key, BarChart3 } from 'lucide-react'
|
||||
import { useProfile } from '../context/ProfileContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Avatar } from './ProfileSelect'
|
||||
import { api } from '../utils/api'
|
||||
import AdminPanel from './AdminPanel'
|
||||
import FeatureUsageOverview from '../components/FeatureUsageOverview'
|
||||
import UsageBadge from '../components/UsageBadge'
|
||||
|
||||
|
|
@ -96,8 +95,7 @@ function ProfileForm({ profile, onSave, onCancel, title }) {
|
|||
|
||||
export default function SettingsPage() {
|
||||
const { profiles, activeProfile, setActiveProfile, refreshProfiles } = useProfile()
|
||||
const { logout, isAdmin, canExport } = useAuth()
|
||||
const [adminOpen, setAdminOpen] = useState(false)
|
||||
const { logout, canExport } = useAuth()
|
||||
const [pinOpen, setPinOpen] = useState(false)
|
||||
const [newPin, setNewPin] = useState('')
|
||||
const [pinMsg, setPinMsg] = useState(null)
|
||||
|
|
@ -375,22 +373,6 @@ export default function SettingsPage() {
|
|||
<FeatureUsageOverview />
|
||||
</div>
|
||||
|
||||
{/* Admin Panel */}
|
||||
{isAdmin && (
|
||||
<div className="card section-gap">
|
||||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between'}}>
|
||||
<div className="card-title" style={{margin:0,display:'flex',alignItems:'center',gap:6}}>
|
||||
<Shield size={15} color="var(--accent)"/> Admin
|
||||
</div>
|
||||
<button className="btn btn-secondary" style={{fontSize:12}}
|
||||
onClick={()=>setAdminOpen(o=>!o)}>
|
||||
{adminOpen?'Schließen':'Öffnen'}
|
||||
</button>
|
||||
</div>
|
||||
{adminOpen && <div style={{marginTop:12}}><AdminPanel/></div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export */}
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Daten exportieren</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user