shinkan-jinkendo/frontend/src/pages/AccountSettingsPage.jsx
Lars 18fa4de055
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 26s
Test Suite / pytest-backend (pull_request) Successful in 5s
Test Suite / lint-backend (pull_request) Successful in 1s
Test Suite / build-frontend (pull_request) Successful in 6s
Test Suite / playwright-tests (pull_request) Successful in 23s
feat: add system information page and update account settings
- Introduced a new SettingsSystemInfoPage to display technical system information.
- Updated AccountSettingsPage to include a link to the new system information page, enhancing user access to app version, build, environment, and database schema details.
- Removed unused version state from Dashboard component to streamline data handling.
2026-05-07 10:29:14 +02:00

425 lines
14 KiB
JavaScript

import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
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 [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])
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 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 &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>
)}
<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) => (
<div key={c.id}>
<strong style={{ color: 'var(--text1)' }}>{c.name}</strong>
{': '}
{(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' }}>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={4}
/>
</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>
</div>
)
}
export default AccountSettingsPage