- 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.
292 lines
12 KiB
JavaScript
292 lines
12 KiB
JavaScript
import { useState, useEffect } from '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'
|
|
|
|
const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780']
|
|
|
|
function Avatar({ profile, size=36 }) {
|
|
const initials = profile.name.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2)
|
|
return (
|
|
<div style={{width:size,height:size,borderRadius:'50%',background:profile.avatar_color||'#1D9E75',
|
|
display:'flex',alignItems:'center',justifyContent:'center',
|
|
fontSize:size*0.35,fontWeight:700,color:'white',flexShrink:0}}>
|
|
{initials}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function NewProfileForm({ onSave, onCancel }) {
|
|
const [form, setForm] = useState({
|
|
name:'', pin:'', email:'', avatar_color:COLORS[0],
|
|
sex:'m', height:'', auth_type:'pin', session_days:30
|
|
})
|
|
const [error, setError] = useState(null)
|
|
const set = (k,v) => setForm(f=>({...f,[k]:v}))
|
|
|
|
const handleSave = async () => {
|
|
if (!form.name.trim()) return setError('Name eingeben')
|
|
if (form.pin.length < 4) return setError('PIN mind. 4 Zeichen')
|
|
try {
|
|
await onSave({...form, height:parseFloat(form.height)||178})
|
|
} catch(e) { setError(e.message) }
|
|
}
|
|
|
|
return (
|
|
<div style={{background:'var(--surface2)',borderRadius:10,padding:14,
|
|
border:'1.5px solid var(--accent)',marginBottom:12}}>
|
|
<div style={{fontWeight:600,fontSize:14,marginBottom:12,color:'var(--accent)'}}>Neues Profil</div>
|
|
<div className="form-row">
|
|
<label className="form-label">Name</label>
|
|
<input type="text" className="form-input" value={form.name} onChange={e=>set('name',e.target.value)} autoFocus/>
|
|
<span className="form-unit"/>
|
|
</div>
|
|
<div style={{marginBottom:10}}>
|
|
<div style={{fontSize:11,color:'var(--text3)',marginBottom:6}}>Farbe</div>
|
|
<div style={{display:'flex',gap:6}}>
|
|
{COLORS.map(c=>(
|
|
<div key={c} onClick={()=>set('avatar_color',c)}
|
|
style={{width:24,height:24,borderRadius:'50%',background:c,cursor:'pointer',
|
|
border:`3px solid ${form.avatar_color===c?'white':'transparent'}`,
|
|
boxShadow:form.avatar_color===c?`0 0 0 2px ${c}`:'none'}}/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="form-row">
|
|
<label className="form-label">Geschlecht</label>
|
|
<select className="form-select" value={form.sex} onChange={e=>set('sex',e.target.value)}>
|
|
<option value="m">Männlich</option>
|
|
<option value="f">Weiblich</option>
|
|
</select>
|
|
</div>
|
|
<div className="form-row">
|
|
<label className="form-label">Größe</label>
|
|
<input type="number" className="form-input" placeholder="178" value={form.height} onChange={e=>set('height',e.target.value)}/>
|
|
<span className="form-unit">cm</span>
|
|
</div>
|
|
<div className="form-row">
|
|
<label className="form-label">E-Mail</label>
|
|
<input type="email" className="form-input" placeholder="optional, für Recovery"
|
|
value={form.email} onChange={e=>set('email',e.target.value)}/>
|
|
<span className="form-unit"/>
|
|
</div>
|
|
<div className="form-row">
|
|
<label className="form-label">Login</label>
|
|
<select className="form-select" value={form.auth_type} onChange={e=>set('auth_type',e.target.value)}>
|
|
<option value="pin">PIN</option>
|
|
<option value="password">Passwort</option>
|
|
</select>
|
|
</div>
|
|
<div className="form-row">
|
|
<label className="form-label">PIN/Passwort</label>
|
|
<input type="password" className="form-input" placeholder="Mind. 4 Zeichen"
|
|
value={form.pin} onChange={e=>set('pin',e.target.value)}/>
|
|
<span className="form-unit"/>
|
|
</div>
|
|
<div className="form-row">
|
|
<label className="form-label">Session</label>
|
|
<select className="form-select" value={form.session_days} onChange={e=>set('session_days',parseInt(e.target.value))}>
|
|
<option value={7}>7 Tage</option>
|
|
<option value={30}>30 Tage</option>
|
|
<option value={0}>Immer</option>
|
|
</select>
|
|
</div>
|
|
{error && <div style={{color:'#D85A30',fontSize:12,marginBottom:8}}>{error}</div>}
|
|
<div style={{display:'flex',gap:8}}>
|
|
<button className="btn btn-primary" style={{flex:1}} onClick={handleSave}><Check size={13}/> Erstellen</button>
|
|
<button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EmailEditor({ profileId, currentEmail, onSaved }) {
|
|
const [email, setEmail] = useState(currentEmail||'')
|
|
const [msg, setMsg] = useState(null)
|
|
const save = async () => {
|
|
const token = localStorage.getItem('bodytrack_token')||''
|
|
await fetch(`/api/admin/profiles/${profileId}/email`, {
|
|
method:'PUT', headers:{'Content-Type':'application/json','X-Auth-Token':token},
|
|
body: JSON.stringify({email})
|
|
})
|
|
setMsg('✓ Gespeichert'); onSaved()
|
|
setTimeout(()=>setMsg(null),2000)
|
|
}
|
|
return (
|
|
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
|
<input type="email" className="form-input" placeholder="email@beispiel.de"
|
|
value={email} onChange={e=>setEmail(e.target.value)} style={{flex:1}}/>
|
|
<button className="btn btn-secondary" onClick={save}>Setzen</button>
|
|
{msg && <span style={{fontSize:11,color:'var(--accent)'}}>{msg}</span>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ProfileCard({ profile, currentId, onRefresh }) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const [perms, setPerms] = useState({
|
|
role: profile.role || 'user',
|
|
})
|
|
const [saving, setSaving] = useState(false)
|
|
const [newPin, setNewPin] = useState('')
|
|
const [pinMsg, setPinMsg] = useState(null)
|
|
const isSelf = profile.id === currentId
|
|
|
|
const savePerms = async () => {
|
|
setSaving(true)
|
|
try {
|
|
await api.adminSetPermissions(profile.id, {
|
|
role: perms.role,
|
|
})
|
|
await onRefresh()
|
|
} finally { setSaving(false) }
|
|
}
|
|
|
|
const savePin = async () => {
|
|
if (newPin.length < 4) return setPinMsg('Mind. 4 Zeichen')
|
|
try {
|
|
await fetch(`/api/admin/profiles/${profile.id}/pin`, {
|
|
method:'PUT', headers:{'Content-Type':'application/json',
|
|
'X-Auth-Token': localStorage.getItem('bodytrack_token')||''},
|
|
body: JSON.stringify({pin: newPin})
|
|
})
|
|
setNewPin(''); setPinMsg('✓ PIN geändert')
|
|
setTimeout(()=>setPinMsg(null),2000)
|
|
} catch(e) { setPinMsg('Fehler: '+e.message) }
|
|
}
|
|
|
|
const deleteProfile = async () => {
|
|
if (!confirm(`Profil "${profile.name}" und ALLE Daten löschen?`)) return
|
|
await api.adminDeleteProfile(profile.id)
|
|
await onRefresh()
|
|
}
|
|
|
|
return (
|
|
<div className="card section-gap">
|
|
<div style={{display:'flex',alignItems:'center',gap:10}}>
|
|
<Avatar profile={profile}/>
|
|
<div style={{flex:1}}>
|
|
<div style={{fontWeight:600,fontSize:14,display:'flex',alignItems:'center',gap:6}}>
|
|
{profile.name}
|
|
{profile.role==='admin' && <span style={{fontSize:10,color:'var(--accent)',background:'var(--accent-light)',padding:'1px 5px',borderRadius:4}}>👑 Admin</span>}
|
|
{isSelf && <span style={{fontSize:10,color:'var(--text3)'}}>Du</span>}
|
|
</div>
|
|
<div style={{fontSize:11,color:'var(--text3)'}}>
|
|
Tier: {profile.tier || 'free'} ·
|
|
Email: {profile.email || 'nicht gesetzt'}
|
|
</div>
|
|
</div>
|
|
<div style={{display:'flex',gap:6}}>
|
|
<button className="btn btn-secondary" style={{padding:'5px 8px'}}
|
|
onClick={()=>setExpanded(e=>!e)}>
|
|
<Pencil size={12}/>
|
|
</button>
|
|
{!isSelf && (
|
|
<button className="btn btn-danger" style={{padding:'5px 8px'}}
|
|
onClick={deleteProfile}>
|
|
<Trash2 size={12}/>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div style={{marginTop:12,paddingTop:12,borderTop:'1px solid var(--border)'}}>
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BERECHTIGUNGEN</div>
|
|
|
|
<div style={{marginBottom:8}}>
|
|
<div style={{fontSize:12,color:'var(--text3)',marginBottom:4}}>Rolle</div>
|
|
<div style={{display:'flex',gap:6}}>
|
|
{['user','admin'].map(r=>(
|
|
<button key={r} onClick={()=>setPerms(p=>({...p,role:r}))}
|
|
style={{flex:1,padding:'6px',borderRadius:8,border:`1.5px solid ${perms.role===r?'var(--accent)':'var(--border2)'}`,
|
|
background:perms.role===r?'var(--accent-light)':'var(--surface)',
|
|
color:perms.role===r?'var(--accent-dark)':'var(--text2)',
|
|
fontFamily:'var(--font)',fontSize:13,cursor:'pointer'}}>
|
|
{r==='admin'?'👑 Admin':'👤 Nutzer'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button className="btn btn-primary btn-full" style={{marginTop:8}} onClick={savePerms} disabled={saving}>
|
|
{saving?'Speichern…':'Rolle speichern'}
|
|
</button>
|
|
</div>
|
|
|
|
<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}}>
|
|
User Feature-Overrides
|
|
</Link>{' '}
|
|
Seite um individuelle Limits zu setzen.
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<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
|
|
</div>
|
|
<div style={{display:'flex',gap:8}}>
|
|
<input type="password" className="form-input" placeholder="Neue PIN/Passwort"
|
|
value={newPin} onChange={e=>setNewPin(e.target.value)} style={{flex:1}}/>
|
|
<button className="btn btn-secondary" onClick={savePin}>Setzen</button>
|
|
</div>
|
|
{pinMsg && <div style={{fontSize:11,color:pinMsg.startsWith('✓')?'var(--accent)':'#D85A30',marginTop:4}}>{pinMsg}</div>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function AdminUsersPage() {
|
|
const { session } = useAuth()
|
|
const [profiles, setProfiles] = useState([])
|
|
const [creating, setCreating] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
const load = () => api.adminListProfiles().then(data=>{ setProfiles(data); setLoading(false) })
|
|
useEffect(()=>{ load() },[])
|
|
|
|
const handleCreate = async (form) => {
|
|
await api.adminCreateProfile(form)
|
|
setCreating(false)
|
|
await load()
|
|
}
|
|
|
|
if (loading) return <div className="empty-state"><div className="spinner"/></div>
|
|
|
|
return (
|
|
<div>
|
|
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:16}}>
|
|
<Shield size={18} color="var(--accent)"/>
|
|
<h2 style={{fontSize:17,fontWeight:700,margin:0}}>Benutzerverwaltung</h2>
|
|
</div>
|
|
|
|
<div style={{padding:'10px 12px',background:'var(--accent-light)',borderRadius:8,
|
|
fontSize:12,color:'var(--accent-dark)',marginBottom:16,lineHeight:1.5}}>
|
|
👑 Profile anlegen, Rollen setzen und Recovery-E-Mail pro Nutzer pflegen. Feature-Limits über „User-Overrides“ in der Seitenleiste.
|
|
</div>
|
|
|
|
{creating && (
|
|
<NewProfileForm onSave={handleCreate} onCancel={()=>setCreating(false)}/>
|
|
)}
|
|
|
|
{profiles.map(p=>(
|
|
<ProfileCard key={p.id} profile={p} currentId={session?.profile_id} onRefresh={load}/>
|
|
))}
|
|
|
|
{!creating && (
|
|
<button className="btn btn-secondary btn-full" style={{marginTop:12}}
|
|
onClick={()=>setCreating(true)}>
|
|
<Plus size={14}/> Neues Profil anlegen
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|