shinkan-jinkendo/frontend/src/pages/AccountSettingsPage.jsx
Lars 5ea225ec6b
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 46s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 16s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Failing after 5m8s
Show a scannable QR code when setting up two-factor authentication.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 08:15:51 +02:00

622 lines
23 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'
import TotpSetupCard from '../components/TotpSetupCard'
/**
* 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 [currentPw, setCurrentPw] = useState('')
const [disablePw, setDisablePw] = useState('')
const [disableCode, setDisableCode] = useState('')
const [disablingTotp, setDisablingTotp] = useState(false)
const [freshRecoveryCodes, setFreshRecoveryCodes] = useState([])
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 < 8) {
showErr('Neues Passwort: mindestens 8 Zeichen.')
return
}
if (newPw1 !== newPw2) {
showErr('Die Passwörter stimmen nicht überein.')
return
}
setSavingPw(true)
try {
const result = await api.changePassword(currentPw, newPw1)
if (result?.token) localStorage.setItem('authToken', result.token)
setCurrentPw('')
setNewPw1('')
setNewPw2('')
showOk('Passwort aktualisiert. Andere Geräte sind abgemeldet.')
} 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 &amp; 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>
)}
{user?.totp_enabled ? (
<div className="card" style={{ marginBottom: '1rem' }}>
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor ist aktiv</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
Beim Anmelden wird zusätzlich der Code aus der Authenticator-App verlangt.
Die ersten Codes lassen sich nicht erneut anzeigen. Hier entstehen neue, die alten gelten danach nicht mehr.
</p>
{freshRecoveryCodes.length > 0 ? (
<div style={{ marginBottom: '1rem' }}>
<textarea
readOnly
rows={8}
className="form-input"
value={freshRecoveryCodes.join('\n')}
style={{ fontFamily: 'ui-monospace, monospace', marginBottom: '0.75rem' }}
/>
<button
type="button"
className="btn btn-secondary"
style={{ marginRight: '0.5rem' }}
onClick={() => navigator.clipboard?.writeText(freshRecoveryCodes.join('\n'))}
>
Kopieren
</button>
<button type="button" className="btn btn-primary" onClick={() => setFreshRecoveryCodes([])}>
Ich habe die Codes gespeichert
</button>
</div>
) : null}
<form
onSubmit={async (e) => {
e.preventDefault()
setDisablingTotp(true)
try {
const data = await api.regenerateRecoveryCodes(disablePw, disableCode.trim())
setFreshRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
setDisableCode('')
showOk('Neue Wiederherstellungscodes sind da. Die bisherigen gelten nicht mehr.')
} catch (err) {
showErr(err.message || 'Neue Codes konnten nicht erzeugt werden')
} finally {
setDisablingTotp(false)
}
}}
>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="totp-off-pw">Aktuelles Passwort</label>
<input id="totp-off-pw" type="password" className="form-input" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} autoComplete="current-password" />
</div>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="totp-off-code">Aktueller Code aus der App</label>
<input id="totp-off-code" className="form-input" value={disableCode} onChange={(e) => setDisableCode(e.target.value)} autoComplete="one-time-code" />
</div>
<button type="submit" className="btn btn-secondary" disabled={disablingTotp} style={{ marginRight: '0.5rem' }}>
{disablingTotp ? 'Bitte warten…' : 'Neue Wiederherstellungscodes'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={disablingTotp}
onClick={async () => {
setDisablingTotp(true)
try {
await api.disableTotp(disablePw, disableCode.trim())
setDisablePw('')
setDisableCode('')
setFreshRecoveryCodes([])
showOk('Zwei-Faktor ist aus. Den Eintrag in der Authenticator-App kannst du löschen.')
await checkAuth()
} catch (err) {
showErr(err.message || 'Abschalten fehlgeschlagen')
} finally {
setDisablingTotp(false)
}
}}
>
Zwei-Faktor ausschalten
</button>
</form>
</div>
) : (
<TotpSetupCard onEnabled={() => checkAuth()} />
)}
<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 &amp; 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 }}>
Das aktuelle Passwort wird geprüft. Danach sind andere Sitzungen ungültig. Mindestens 8 Zeichen.
</p>
<form onSubmit={handleChangePassword}>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
<label className="form-label" htmlFor="settings-pw-current">
Aktuelles Passwort
</label>
<input
id="settings-pw-current"
type="password"
className="form-input"
value={currentPw}
onChange={(e) => setCurrentPw(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<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