All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 56s
- Introduced `training_planning_prefs` field in the ProfileUpdate model to store user-specific UI options for training planning. - Updated the backend to handle the new preferences during profile updates, ensuring proper validation and storage. - Enhanced the frontend to allow users to select their preferred display mode for training modules in the Account Settings page. - Updated version to 0.8.98 and adjusted database schema version accordingly, reflecting the new feature integration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
516 lines
18 KiB
JavaScript
516 lines
18 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import { useAuth } from '../context/AuthContext'
|
|
import {
|
|
PLANNING_MODULE_DISPLAY_MODES,
|
|
resolvePlanningModuleDisplayMode,
|
|
} from '../config/planningModuleUx'
|
|
import api from '../utils/api'
|
|
|
|
/**
|
|
* Persönliche Einstellungen (Anzeige/Name, Kontostatus, Passwort).
|
|
*/
|
|
function AccountSettingsPage() {
|
|
const { user, checkAuth } = useAuth()
|
|
const [name, setName] = useState('')
|
|
const [savingProfile, setSavingProfile] = useState(false)
|
|
|
|
const [publicClubsDir, setPublicClubsDir] = useState([])
|
|
const [myJoinRequests, setMyJoinRequests] = useState([])
|
|
const [joinClubId, setJoinClubId] = useState('')
|
|
const [joinMessage, setJoinMessage] = useState('')
|
|
const [joinBusy, setJoinBusy] = useState(false)
|
|
|
|
const [planningPrefsBusy, setPlanningPrefsBusy] = useState(false)
|
|
/** @type {[string, React.Dispatch<React.SetStateAction<string>>]} */
|
|
const [moduleDisplayDraft, setModuleDisplayDraft] = useState(
|
|
PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND
|
|
)
|
|
|
|
const [newPw1, setNewPw1] = useState('')
|
|
const [newPw2, setNewPw2] = useState('')
|
|
const [savingPw, setSavingPw] = useState(false)
|
|
const [resendingVerify, setResendingVerify] = useState(false)
|
|
|
|
const [message, setMessage] = useState('')
|
|
const [error, setError] = useState('')
|
|
|
|
useEffect(() => {
|
|
setName(typeof user?.name === 'string' ? user.name : '')
|
|
}, [user])
|
|
|
|
useEffect(() => {
|
|
const m = resolvePlanningModuleDisplayMode(user?.training_planning_prefs?.module_display_mode)
|
|
setModuleDisplayDraft(m)
|
|
}, [user?.id, user?.training_planning_prefs])
|
|
|
|
const refreshJoinRequests = () => {
|
|
api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!user?.id) return
|
|
api.listPublicClubsDirectory().then(setPublicClubsDir).catch(() => {})
|
|
refreshJoinRequests()
|
|
}, [user?.id])
|
|
|
|
const memberClubIds = new Set((user?.clubs || []).map((c) => c.id))
|
|
const pendingClubIds = new Set(
|
|
myJoinRequests.filter((r) => r.status === 'pending').map((r) => r.club_id)
|
|
)
|
|
const joinClubChoices = publicClubsDir.filter(
|
|
(c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id)
|
|
)
|
|
|
|
const joinStatusLabel = (s) =>
|
|
({
|
|
pending: 'ausstehend',
|
|
accepted: 'angenommen',
|
|
rejected: 'abgelehnt',
|
|
withdrawn: 'zurückgezogen',
|
|
})[s] || s
|
|
|
|
/** API: boolean true / Legacy: fehlt oder false → als „nicht verifiziert“ behandeln */
|
|
const emailExplicitlyVerified =
|
|
user?.email_verified === true ||
|
|
user?.email_verified === 't' ||
|
|
user?.email_verified === 1 ||
|
|
user?.email_verified === 'true'
|
|
|
|
const showOk = (text) => {
|
|
setMessage(text)
|
|
setError('')
|
|
setTimeout(() => setMessage(''), 5000)
|
|
}
|
|
|
|
const showErr = (text) => {
|
|
setError(text)
|
|
setMessage('')
|
|
}
|
|
|
|
const handleSaveName = async (e) => {
|
|
e.preventDefault()
|
|
if (!user?.id) return
|
|
const trimmed = (name || '').trim()
|
|
if (trimmed.length < 2) {
|
|
showErr('Name sollte mindestens 2 Zeichen haben.')
|
|
return
|
|
}
|
|
setSavingProfile(true)
|
|
try {
|
|
await api.updateProfile(user.id, { name: trimmed })
|
|
await checkAuth()
|
|
showOk('Profilname gespeichert.')
|
|
} catch (err) {
|
|
showErr(err.message || 'Speichern fehlgeschlagen.')
|
|
} finally {
|
|
setSavingProfile(false)
|
|
}
|
|
}
|
|
|
|
const handleSavePlanningPrefs = async (e) => {
|
|
e.preventDefault()
|
|
if (!user?.id) return
|
|
setPlanningPrefsBusy(true)
|
|
try {
|
|
const base =
|
|
user.training_planning_prefs && typeof user.training_planning_prefs === 'object'
|
|
? { ...user.training_planning_prefs }
|
|
: {}
|
|
const merged = { ...base, module_display_mode: moduleDisplayDraft }
|
|
await api.updateProfile(user.id, { training_planning_prefs: merged })
|
|
await checkAuth()
|
|
showOk('Planungs-Anzeige gespeichert.')
|
|
} catch (err) {
|
|
showErr(err.message || 'Konnte nicht speichern.')
|
|
} finally {
|
|
setPlanningPrefsBusy(false)
|
|
}
|
|
}
|
|
|
|
const handleResendVerification = async () => {
|
|
const em = user?.email
|
|
if (!em) return
|
|
setResendingVerify(true)
|
|
try {
|
|
await api.resendVerification(em)
|
|
showOk('Falls diese Adresse einen unbestätigten Account hat: E-Mail ist unterwegs — Postfach prüfen.')
|
|
} catch (err) {
|
|
showErr(err.message || 'Konnte keine E-Mail senden.')
|
|
} finally {
|
|
setResendingVerify(false)
|
|
}
|
|
}
|
|
|
|
const handleChangePassword = async (e) => {
|
|
e.preventDefault()
|
|
if (newPw1.length < 4) {
|
|
showErr('Neues Passwort: mindestens 4 Zeichen.')
|
|
return
|
|
}
|
|
if (newPw1 !== newPw2) {
|
|
showErr('Die Passwörter stimmen nicht überein.')
|
|
return
|
|
}
|
|
setSavingPw(true)
|
|
try {
|
|
await api.changePassword(newPw1)
|
|
setNewPw1('')
|
|
setNewPw2('')
|
|
showOk('Passwort aktualisiert.')
|
|
} catch (err) {
|
|
showErr(err.message || 'Passwort konnte nicht geändert werden.')
|
|
} finally {
|
|
setSavingPw(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="page-padding app-page" style={{ padding: '1rem' }}>
|
|
<h1 style={{ marginBottom: '0.35rem', fontSize: '1.5rem' }}>Einstellungen</h1>
|
|
<p style={{ color: 'var(--text2)', marginBottom: '1.25rem', fontSize: '0.95rem' }}>
|
|
Konto & Sicherheit
|
|
</p>
|
|
|
|
{message && (
|
|
<div
|
|
style={{
|
|
padding: '0.75rem',
|
|
borderRadius: 'var(--radius, 12px)',
|
|
background: 'var(--accent-soft, rgba(29,158,117,0.15))',
|
|
color: 'var(--text1)',
|
|
marginBottom: '1rem',
|
|
border: '1px solid var(--accent)',
|
|
}}
|
|
>
|
|
{message}
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<div
|
|
style={{
|
|
padding: '0.75rem',
|
|
borderRadius: 'var(--radius, 12px)',
|
|
background: 'rgba(216,90,48,0.15)',
|
|
color: 'var(--text1)',
|
|
marginBottom: '1rem',
|
|
border: '1px solid var(--danger)',
|
|
}}
|
|
>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="card" style={{ marginBottom: '1rem' }}>
|
|
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Profil</h2>
|
|
<div style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
|
<strong style={{ color: 'var(--text1)' }}>E-Mail</strong>
|
|
<br />
|
|
{user?.email || '—'}{' '}
|
|
{emailExplicitlyVerified ? (
|
|
<span
|
|
style={{
|
|
marginLeft: '0.5rem',
|
|
fontSize: '0.75rem',
|
|
padding: '0.15rem 0.5rem',
|
|
borderRadius: '6px',
|
|
background: 'var(--accent-soft, rgba(29,158,117,0.2))',
|
|
color: 'var(--accent-dark, #085041)',
|
|
}}
|
|
>
|
|
bestätigt
|
|
</span>
|
|
) : (
|
|
<span
|
|
style={{
|
|
marginLeft: '0.5rem',
|
|
fontSize: '0.75rem',
|
|
padding: '0.15rem 0.5rem',
|
|
borderRadius: '6px',
|
|
background: 'var(--surface2)',
|
|
color: 'var(--text2)',
|
|
}}
|
|
>
|
|
noch nicht bestätigt
|
|
</span>
|
|
)}
|
|
{!emailExplicitlyVerified && user?.email ? (
|
|
<div style={{ marginTop: '0.75rem' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={resendingVerify}
|
|
onClick={handleResendVerification}
|
|
>
|
|
{resendingVerify ? 'Sende…' : 'Bestätigung erneut senden'}
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<form onSubmit={handleSaveName}>
|
|
<label className="form-label" htmlFor="settings-name">
|
|
Anzeigename
|
|
</label>
|
|
<input
|
|
id="settings-name"
|
|
type="text"
|
|
className="form-input"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="Dein Name in der App"
|
|
autoComplete="nickname"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="btn btn-primary"
|
|
disabled={savingProfile}
|
|
style={{ marginTop: '0.85rem' }}
|
|
>
|
|
{savingProfile ? 'Speichern…' : 'Name speichern'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="card" style={{ marginBottom: '1rem' }}>
|
|
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Rollen & Tarif</h2>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: '0.5rem 1rem', fontSize: '0.925rem' }}>
|
|
<strong style={{ color: 'var(--text2)' }}>Rolle</strong>
|
|
<span>{user?.role === 'admin' ? 'Administrator' : user?.role || 'trainer'}</span>
|
|
|
|
<strong style={{ color: 'var(--text2)' }}>Tier</strong>
|
|
<span style={{ textTransform: 'uppercase', letterSpacing: '0.03em', fontWeight: 600 }}>
|
|
{user?.tier || 'free'}
|
|
</span>
|
|
|
|
<strong style={{ color: 'var(--text2)' }}>Vereine</strong>
|
|
<span style={{ lineHeight: 1.45 }}>
|
|
{user?.clubs?.length ? (
|
|
<>
|
|
{user.clubs.map((c) => {
|
|
const mem = (c.membership_status || 'active').toString().trim().toLowerCase()
|
|
const inactive = mem === 'inactive'
|
|
return (
|
|
<div key={c.id}>
|
|
<strong style={{ color: 'var(--text1)' }}>{c.name}</strong>
|
|
{inactive ? (
|
|
<span style={{ color: 'var(--warning, #d4a012)', marginLeft: '0.35rem', fontSize: '0.82rem' }}>
|
|
(Vereinszugang deaktiviert)
|
|
</span>
|
|
) : null}
|
|
{': '}
|
|
{(c.roles || []).length ? (c.roles || []).join(', ') : '—'}
|
|
</div>
|
|
)
|
|
})}
|
|
</>
|
|
) : (
|
|
'—'
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card" style={{ marginBottom: '1rem' }}>
|
|
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Trainingsplanung</h2>
|
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
|
Wie kopiert aus der Modul-Bibliothek übernommenen Blöcken in einer Einheit dargestellt werden.
|
|
</p>
|
|
<form onSubmit={handleSavePlanningPrefs}>
|
|
<fieldset style={{ margin: 0, padding: 0, border: 'none' }}>
|
|
<legend className="form-label" style={{ marginBottom: '0.5rem' }}>
|
|
Darstellung „Aus Modul“
|
|
</legend>
|
|
<label
|
|
style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', cursor: 'pointer', marginBottom: '0.65rem' }}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="planning-module-display"
|
|
checked={moduleDisplayDraft === PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND}
|
|
onChange={() => setModuleDisplayDraft(PLANNING_MODULE_DISPLAY_MODES.COMPACT_TAG_LEGEND)}
|
|
/>
|
|
<span style={{ lineHeight: 1.45, fontSize: '0.9rem', color: 'var(--text1)' }}>
|
|
<strong>Kompakt</strong> — farbige Markierung je Modul, Tag pro Zeile, Legende im Abschnitt.
|
|
</span>
|
|
</label>
|
|
<label
|
|
style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-start', cursor: 'pointer', marginBottom: '0.75rem' }}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="planning-module-display"
|
|
checked={moduleDisplayDraft === PLANNING_MODULE_DISPLAY_MODES.FULL_OUTLINE_HEADERS}
|
|
onChange={() => setModuleDisplayDraft(PLANNING_MODULE_DISPLAY_MODES.FULL_OUTLINE_HEADERS)}
|
|
/>
|
|
<span style={{ lineHeight: 1.45, fontSize: '0.9rem', color: 'var(--text1)' }}>
|
|
<strong>Ausführlich</strong> — großer Modul-Kopfbereich mit nummerierter Übungsliste.
|
|
</span>
|
|
</label>
|
|
</fieldset>
|
|
<button type="submit" className="btn btn-secondary" disabled={planningPrefsBusy}>
|
|
{planningPrefsBusy ? 'Speichern…' : 'Anzeige speichern'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="card" style={{ marginBottom: '1rem' }}>
|
|
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Vereinsbeitritt</h2>
|
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
|
Beantrage die Mitgliedschaft in einem Verein. Vereinsadministratoren können den Antrag unter
|
|
„Vereinsverwaltung → Mitglieder“ annehmen oder ablehnen.
|
|
</p>
|
|
|
|
{myJoinRequests.length > 0 && (
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<strong style={{ fontSize: '0.9rem' }}>Meine Anträge</strong>
|
|
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem', color: 'var(--text2)', fontSize: '0.9rem' }}>
|
|
{myJoinRequests.map((r) => (
|
|
<li key={r.id} style={{ marginBottom: '0.35rem' }}>
|
|
{r.club_name || `Verein #${r.club_id}`} — {joinStatusLabel(r.status)}
|
|
{r.status === 'pending' ? (
|
|
<>
|
|
{' '}
|
|
<button
|
|
type="button"
|
|
style={{
|
|
marginLeft: '0.35rem',
|
|
fontSize: '0.75rem',
|
|
padding: '0.15rem 0.45rem',
|
|
borderRadius: '6px',
|
|
border: '1px solid var(--border)',
|
|
background: 'var(--surface2)',
|
|
cursor: 'pointer',
|
|
}}
|
|
onClick={async () => {
|
|
if (!confirm('Antrag wirklich zurückziehen?')) return
|
|
try {
|
|
await api.withdrawClubJoinRequest(r.id)
|
|
refreshJoinRequests()
|
|
} catch (err) {
|
|
showErr(err.message || 'Zurückziehen fehlgeschlagen.')
|
|
}
|
|
}}
|
|
>
|
|
zurückziehen
|
|
</button>
|
|
</>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<form
|
|
onSubmit={async (e) => {
|
|
e.preventDefault()
|
|
if (!joinClubId) {
|
|
showErr('Bitte einen Verein auswählen.')
|
|
return
|
|
}
|
|
setJoinBusy(true)
|
|
try {
|
|
await api.createClubJoinRequest({
|
|
club_id: parseInt(joinClubId, 10),
|
|
message: (joinMessage || '').trim() || undefined,
|
|
})
|
|
setJoinMessage('')
|
|
setJoinClubId('')
|
|
refreshJoinRequests()
|
|
await checkAuth()
|
|
showOk('Antrag gesendet.')
|
|
} catch (err) {
|
|
showErr(err.message || 'Antrag fehlgeschlagen.')
|
|
} finally {
|
|
setJoinBusy(false)
|
|
}
|
|
}}
|
|
>
|
|
<label className="form-label" htmlFor="join-club-select">
|
|
Verein auswählen
|
|
</label>
|
|
<select
|
|
id="join-club-select"
|
|
className="form-input"
|
|
value={joinClubId}
|
|
onChange={(e) => setJoinClubId(e.target.value)}
|
|
>
|
|
<option value="">—</option>
|
|
{joinClubChoices.map((c) => (
|
|
<option key={c.id} value={String(c.id)}>
|
|
{c.name}
|
|
{c.abbreviation ? ` (${c.abbreviation})` : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<label className="form-label" htmlFor="join-msg" style={{ marginTop: '0.75rem' }}>
|
|
Nachricht (optional)
|
|
</label>
|
|
<textarea
|
|
id="join-msg"
|
|
className="form-input"
|
|
rows={2}
|
|
value={joinMessage}
|
|
onChange={(e) => setJoinMessage(e.target.value)}
|
|
placeholder="z. B. Trainingsgruppe oder Kontakt zum Verein"
|
|
/>
|
|
<button type="submit" className="btn btn-primary" disabled={joinBusy} style={{ marginTop: '0.85rem' }}>
|
|
{joinBusy ? 'Senden…' : 'Beitritt beantragen'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Passwort ändern</h2>
|
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
|
Wähle ein neues Passwort (mindestens 4 Zeichen, wie beim Login gewohnt empfehlen wir längere Passwörter).
|
|
</p>
|
|
<form onSubmit={handleChangePassword}>
|
|
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
|
|
<label className="form-label" htmlFor="settings-pw1">
|
|
Neues Passwort
|
|
</label>
|
|
<input
|
|
id="settings-pw1"
|
|
type="password"
|
|
className="form-input"
|
|
value={newPw1}
|
|
onChange={(e) => setNewPw1(e.target.value)}
|
|
autoComplete="new-password"
|
|
minLength={8}
|
|
/>
|
|
</div>
|
|
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
|
|
<label className="form-label" htmlFor="settings-pw2">
|
|
Passwort wiederholen
|
|
</label>
|
|
<input
|
|
id="settings-pw2"
|
|
type="password"
|
|
className="form-input"
|
|
value={newPw2}
|
|
onChange={(e) => setNewPw2(e.target.value)}
|
|
autoComplete="new-password"
|
|
/>
|
|
</div>
|
|
<button type="submit" className="btn btn-secondary" disabled={savingPw}>
|
|
{savingPw ? 'Wird gespeichert…' : 'Passwort speichern'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<p className="muted" style={{ marginTop: '1.75rem', fontSize: '0.875rem', lineHeight: 1.5 }}>
|
|
<Link to="/settings/system">Technische Systeminformationen</Link>
|
|
{' — App-Version, Build, Umgebung, Datenbankschema'}
|
|
</p>
|
|
<p className="muted" style={{ marginTop: '0.6rem', fontSize: '0.875rem', lineHeight: 1.5 }}>
|
|
<Link to="/settings/legal">Rechtliches</Link>
|
|
{' — Impressum, Datenschutz, Nutzungsbedingungen, Medienrichtlinie'}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default AccountSettingsPage
|