Compare commits
No commits in common. "a14353248633b22054a25b1e467fdcb7aa34b797" and "2f54fef88e408f2e06e31f71ce2475e75ddec7cc" have entirely different histories.
a143532486
...
2f54fef88e
|
|
@ -18,12 +18,9 @@ SMTP_FROM=noreply@jinkendo.de
|
||||||
|
|
||||||
# App
|
# App
|
||||||
APP_URL=https://shinkan.jinkendo.de
|
APP_URL=https://shinkan.jinkendo.de
|
||||||
# Kommasepariert (ohne Leerzeichen um die Kommas ist am sichersten). Für Dev mehrere Origins nötig (HTTPS + LAN).
|
|
||||||
ALLOWED_ORIGINS=https://shinkan.jinkendo.de
|
ALLOWED_ORIGINS=https://shinkan.jinkendo.de
|
||||||
ENVIRONMENT=production
|
ENVIRONMENT=production
|
||||||
|
|
||||||
# Nur docker-compose.dev-env.yml (optional): DEV_APP_URL, DEV_ALLOWED_ORIGINS
|
|
||||||
|
|
||||||
# Media Storage
|
# Media Storage
|
||||||
MEDIA_DIR=/app/media
|
MEDIA_DIR=/app/media
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,5 +19,4 @@ jobs:
|
||||||
docker compose -f docker-compose.dev-env.yml up -d
|
docker compose -f docker-compose.dev-env.yml up -d
|
||||||
sleep 5
|
sleep 5
|
||||||
curl -sf http://localhost:8098/api/version && echo "✓ DEV API healthy"
|
curl -sf http://localhost:8098/api/version && echo "✓ DEV API healthy"
|
||||||
curl -sf http://localhost:3098/api/version && echo "✓ DEV über Frontend-Nginx (wie Browser) healthy"
|
|
||||||
echo "=== Shinkan DEV Deploy complete ==="
|
echo "=== Shinkan DEV Deploy complete ==="
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,6 @@ from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from slowapi import _rate_limit_exceeded_handler
|
|
||||||
from slowapi.errors import RateLimitExceeded
|
|
||||||
|
|
||||||
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
|
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
|
||||||
|
|
||||||
# Run database migrations on startup
|
# Run database migrations on startup
|
||||||
|
|
@ -25,8 +22,6 @@ except Exception as e:
|
||||||
print(f"⚠ Warning: Migration error: {e}")
|
print(f"⚠ Warning: Migration error: {e}")
|
||||||
print(" Continuing startup - migrations may need manual intervention")
|
print(" Continuing startup - migrations may need manual intervention")
|
||||||
|
|
||||||
from routers.auth import limiter as auth_rate_limiter
|
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Shinkan Jinkendo API",
|
title="Shinkan Jinkendo API",
|
||||||
|
|
@ -34,13 +29,8 @@ app = FastAPI(
|
||||||
version=APP_VERSION
|
version=APP_VERSION
|
||||||
)
|
)
|
||||||
|
|
||||||
# SlowAPI: Rate Limits auf /api/auth/* (Decorator in routers/auth.py)
|
# CORS Configuration
|
||||||
app.state.limiter = auth_rate_limiter
|
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:3098").split(",")
|
||||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
|
||||||
|
|
||||||
# CORS — kommaseparierte Liste (z. B. https://dev.shinkan… und http://192.168.x.x:3098)
|
|
||||||
_cors_raw = os.getenv("ALLOWED_ORIGINS", "http://localhost:3098")
|
|
||||||
ALLOWED_ORIGINS = [o.strip() for o in _cors_raw.split(",") if o.strip()]
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,9 @@ async def login(req: LoginRequest, request: Request):
|
||||||
return {
|
return {
|
||||||
"token": token,
|
"token": token,
|
||||||
"profile_id": prof['id'],
|
"profile_id": prof['id'],
|
||||||
"email": prof.get("email"),
|
"name": prof['name'],
|
||||||
"name": prof.get("name"),
|
"role": prof['role'],
|
||||||
"role": prof.get("role"),
|
"expires_at": expires.isoformat()
|
||||||
"tier": prof.get("tier"),
|
|
||||||
"expires_at": expires.isoformat(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -237,17 +235,18 @@ async def register(req: RegisterRequest, request: Request):
|
||||||
verification_token = secrets.token_urlsafe(32)
|
verification_token = secrets.token_urlsafe(32)
|
||||||
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||||
|
|
||||||
# Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen.
|
# Create profile (inactive until verified)
|
||||||
|
profile_id = str(secrets.token_hex(16))
|
||||||
pin_hash = hash_pin(password)
|
pin_hash = hash_pin(password)
|
||||||
trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial
|
trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial
|
||||||
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO profiles (
|
INSERT INTO profiles (
|
||||||
name, email, pin_hash, auth_type, role, tier,
|
id, name, email, pin_hash, auth_type, role, tier,
|
||||||
email_verified, verification_token, verification_expires,
|
email_verified, verification_token, verification_expires,
|
||||||
trial_ends_at, created_at
|
trial_ends_at, created_at
|
||||||
) VALUES (%s, %s, %s, 'email', 'trainer', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
|
) VALUES (%s, %s, %s, %s, 'email', 'trainer', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||||
""", (name, email, pin_hash, verification_token, verification_expires, trial_ends))
|
""", (profile_id, name, email, pin_hash, verification_token, verification_expires, trial_ends))
|
||||||
|
|
||||||
# Send verification email
|
# Send verification email
|
||||||
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
|
app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de")
|
||||||
|
|
|
||||||
|
|
@ -82,12 +82,7 @@ def get_profile(pid: str, session=Depends(require_auth)):
|
||||||
|
|
||||||
@router.put("/profiles/{pid}")
|
@router.put("/profiles/{pid}")
|
||||||
def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
|
def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
|
||||||
"""Update profile — nur eigenes Profil oder Admin."""
|
"""Update profile by ID."""
|
||||||
sess_pid = session.get('profile_id')
|
|
||||||
role = (session.get('role') or '').lower()
|
|
||||||
if str(sess_pid) != str(pid) and role not in ('admin', 'superadmin'):
|
|
||||||
raise HTTPException(403, 'Keine Berechtigung für dieses Profil')
|
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||||
|
|
@ -139,7 +134,7 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)):
|
||||||
if not data:
|
if not data:
|
||||||
return get_profile(pid, session)
|
return get_profile(pid, session)
|
||||||
|
|
||||||
data["updated_at"] = datetime.now()
|
data["updated"] = datetime.now().isoformat()
|
||||||
cols = ", ".join(f"{k}=%s" for k in data)
|
cols = ", ".join(f"{k}=%s" for k in data)
|
||||||
vals = list(data.values()) + [pid]
|
vals = list(data.values()) + [pid]
|
||||||
cur.execute(f"UPDATE profiles SET {cols} WHERE id=%s", vals)
|
cur.execute(f"UPDATE profiles SET {cols} WHERE id=%s", vals)
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,8 @@ services:
|
||||||
SMTP_USER: ${SMTP_USER}
|
SMTP_USER: ${SMTP_USER}
|
||||||
SMTP_PASS: ${SMTP_PASS}
|
SMTP_PASS: ${SMTP_PASS}
|
||||||
SMTP_FROM: ${SMTP_FROM}
|
SMTP_FROM: ${SMTP_FROM}
|
||||||
# Öffentliche Dev-URL (E-Mail-Links); lokaler Zugriff per IP bleibt über ALLOWED_ORIGINS möglich
|
APP_URL: http://192.168.2.49:3098
|
||||||
APP_URL: "${DEV_APP_URL:-https://dev.shinkan.jinkendo.de}"
|
ALLOWED_ORIGINS: http://192.168.2.49:3098
|
||||||
# Login/Register vom Browser: HTTPS-Subdomain und optional LAN-IP (Compose überschreibbar per .env)
|
|
||||||
ALLOWED_ORIGINS: "${DEV_ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}"
|
|
||||||
ENVIRONMENT: development
|
ENVIRONMENT: development
|
||||||
MEDIAWIKI_API_URL: https://karatetrainer.net/api.php
|
MEDIAWIKI_API_URL: https://karatetrainer.net/api.php
|
||||||
MEDIAWIKI_USER: Jinkendo
|
MEDIAWIKI_USER: Jinkendo
|
||||||
|
|
@ -60,14 +58,11 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
# Leer = relative /api/*-URLs → gleicher Host wie die SPA (vermeidet Mixed Content HTTPS→HTTP)
|
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: ""
|
VITE_API_URL: http://192.168.2.49:8098
|
||||||
container_name: dev-shinkan-ui
|
container_name: dev-shinkan-ui
|
||||||
ports:
|
ports:
|
||||||
- "3098:80"
|
- "3098:80"
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- dev-shinkan-network
|
- dev-shinkan-network
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ services:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_API_URL: ""
|
VITE_API_URL: https://shinkan.jinkendo.de
|
||||||
container_name: shinkan-ui
|
container_name: shinkan-ui
|
||||||
ports:
|
ports:
|
||||||
- "3003:80"
|
- "3003:80"
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,12 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# Docker-Embedded DNS: Hostname »backend« bei Container-Neustarts neu auflösen
|
|
||||||
# — verringert sporadische 502, wenn sich nur die Backend-Container-IP geändert hat.
|
|
||||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
|
||||||
|
|
||||||
# Uploads (Übungsmedien) und API erreichen Clients unter derselben Host-URL wie die SPA —
|
# Uploads (Übungsmedien) und API erreichen Clients unter derselben Host-URL wie die SPA —
|
||||||
# dafür muss Nginx zur FastAPI-Instanz im Compose-Netz weiterleiten.
|
# dafür muss Nginx zur FastAPI-Instanz im Compose-Netz weiterleiten.
|
||||||
client_max_body_size 64m;
|
client_max_body_size 64m;
|
||||||
|
|
||||||
location ^~ /api/ {
|
location ^~ /api/ {
|
||||||
set $docker_backend_svc backend;
|
proxy_pass http://backend:8000;
|
||||||
proxy_pass http://$docker_backend_svc:8000$request_uri;
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
@ -26,8 +21,7 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /media/ {
|
location ^~ /media/ {
|
||||||
set $docker_backend_svc backend;
|
proxy_pass http://backend:8000;
|
||||||
proxy_pass http://$docker_backend_svc:8000$request_uri;
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
@ -36,8 +30,7 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /health {
|
location = /health {
|
||||||
set $hc_upstream backend;
|
proxy_pass http://backend:8000/health;
|
||||||
proxy_pass http://$hc_upstream:8000/health;
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import DesktopSidebar from './components/DesktopSidebar'
|
||||||
import { getMainNavItems } from './config/appNav'
|
import { getMainNavItems } from './config/appNav'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import AccountSettingsPage from './pages/AccountSettingsPage'
|
import ProfilePage from './pages/ProfilePage'
|
||||||
import ExercisesListPage from './pages/ExercisesListPage'
|
import ExercisesListPage from './pages/ExercisesListPage'
|
||||||
import ExerciseDetailPage from './pages/ExerciseDetailPage'
|
import ExerciseDetailPage from './pages/ExerciseDetailPage'
|
||||||
import ExerciseFormPage from './pages/ExerciseFormPage'
|
import ExerciseFormPage from './pages/ExerciseFormPage'
|
||||||
|
|
@ -144,8 +144,7 @@ function AppRoutes() {
|
||||||
|
|
||||||
<Route element={<ProtectedLayout />}>
|
<Route element={<ProtectedLayout />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="profile" element={<Navigate to="/settings" replace />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
<Route path="settings" element={<AccountSettingsPage />} />
|
|
||||||
<Route path="exercises">
|
<Route path="exercises">
|
||||||
<Route index element={<ExercisesListPage />} />
|
<Route index element={<ExercisesListPage />} />
|
||||||
<Route path="new" element={<ExerciseFormPage />} />
|
<Route path="new" element={<ExerciseFormPage />} />
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export default function DesktopSidebar({
|
||||||
fontSize: '14px'
|
fontSize: '14px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(user?.name || user?.email || '?').trim().slice(0, 1).toUpperCase()}
|
{user?.name?.charAt(0) || 'U'}
|
||||||
</div>
|
</div>
|
||||||
<div className="desktop-sidebar__user-text">
|
<div className="desktop-sidebar__user-text">
|
||||||
<span className="desktop-sidebar__user-name">
|
<span className="desktop-sidebar__user-name">
|
||||||
|
|
|
||||||
|
|
@ -29,24 +29,8 @@ export function AuthProvider({ children }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fallback, falls ohne checkAuth gesetzt wird (Legacy / Token-Injektion) */
|
const login = (data) => {
|
||||||
const login = (payload) => {
|
setUser(data.profile || data)
|
||||||
if (payload?.profile != null) {
|
|
||||||
setUser(payload.profile)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const p = payload
|
|
||||||
if (p?.profile_id != null || p?.id != null) {
|
|
||||||
setUser({
|
|
||||||
id: p.profile_id ?? p.id,
|
|
||||||
name: p.name ?? null,
|
|
||||||
email: p.email ?? null,
|
|
||||||
role: p.role ?? 'user',
|
|
||||||
tier: p.tier ?? 'free',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setUser(payload)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
|
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
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 [newPw1, setNewPw1] = useState('')
|
|
||||||
const [newPw2, setNewPw2] = useState('')
|
|
||||||
const [savingPw, setSavingPw] = useState(false)
|
|
||||||
|
|
||||||
const [message, setMessage] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setName(typeof user?.name === 'string' ? user.name : '')
|
|
||||||
}, [user])
|
|
||||||
|
|
||||||
const verified = !!user?.email_verified
|
|
||||||
|
|
||||||
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 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" style={{ padding: '1rem', maxWidth: '640px', margin: '0 auto' }}>
|
|
||||||
<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 || '—'}{' '}
|
|
||||||
{verified ? (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AccountSettingsPage
|
|
||||||
|
|
@ -13,7 +13,7 @@ function LoginPage() {
|
||||||
const [success, setSuccess] = useState('')
|
const [success, setSuccess] = useState('')
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { checkAuth } = useAuth()
|
const { login: authLogin } = useAuth()
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
@ -25,7 +25,7 @@ function LoginPage() {
|
||||||
if (mode === 'login') {
|
if (mode === 'login') {
|
||||||
const response = await api.login(email, password)
|
const response = await api.login(email, password)
|
||||||
localStorage.setItem('authToken', response.token)
|
localStorage.setItem('authToken', response.token)
|
||||||
await checkAuth()
|
authLogin({ token: response.token, profile: response.profile })
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} else {
|
} else {
|
||||||
await api.register(email, password, name)
|
await api.register(email, password, name)
|
||||||
|
|
|
||||||
60
frontend/src/pages/ProfilePage.jsx
Normal file
60
frontend/src/pages/ProfilePage.jsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
function ProfilePage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem' }}>
|
||||||
|
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||||
|
<h1>Profil</h1>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginTop: '1.5rem' }}>
|
||||||
|
<h2>Persönliche Daten</h2>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr', gap: '1rem', marginBottom: '1rem' }}>
|
||||||
|
<strong>Name:</strong>
|
||||||
|
<span>{user?.name || '-'}</span>
|
||||||
|
|
||||||
|
<strong>E-Mail:</strong>
|
||||||
|
<span>{user?.email}</span>
|
||||||
|
|
||||||
|
<strong>Rolle:</strong>
|
||||||
|
<span style={{
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: user?.role === 'admin' ? 'var(--accent)' : 'var(--surface2)',
|
||||||
|
color: user?.role === 'admin' ? 'white' : 'var(--text1)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
display: 'inline-block',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}>
|
||||||
|
{user?.role || 'user'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<strong>Tier:</strong>
|
||||||
|
<span style={{
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
background: user?.tier === 'premium' ? 'var(--accent)' : 'var(--surface2)',
|
||||||
|
color: user?.tier === 'premium' ? 'white' : 'var(--text1)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
display: 'inline-block',
|
||||||
|
fontSize: '0.875rem'
|
||||||
|
}}>
|
||||||
|
{user?.tier || 'free'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ marginTop: '1.5rem' }}>
|
||||||
|
<h2>Einstellungen</h2>
|
||||||
|
<p style={{ color: 'var(--text2)', marginTop: '0.5rem' }}>
|
||||||
|
Bearbeitung folgt in Kürze
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfilePage
|
||||||
|
|
@ -32,24 +32,8 @@ async function request(endpoint, options = {}) {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text()
|
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||||
let parsed = null
|
throw new Error(error.detail || `HTTP ${response.status}`)
|
||||||
try {
|
|
||||||
parsed = JSON.parse(text)
|
|
||||||
} catch {
|
|
||||||
parsed = null
|
|
||||||
}
|
|
||||||
if (parsed?.detail != null) {
|
|
||||||
const d = parsed.detail
|
|
||||||
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
|
|
||||||
}
|
|
||||||
if (response.status === 502) {
|
|
||||||
throw new Error(
|
|
||||||
'HTTP 502 (Bad Gateway): Der Reverse-Proxy hat die API nicht korrekt erreicht. Ist `shinkan-api` aktiv (`docker compose ps`, `docker logs shinkan-api`)? Bei Host-Routing nur einen Weg verwenden — alles auf Port 3003 (Nginx nach `backend:8000`) oder sauber `/api` → Backend-Port.'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
|
||||||
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
@ -93,20 +77,6 @@ export async function getCurrentProfile() {
|
||||||
return request('/api/profiles/me')
|
return request('/api/profiles/me')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProfile(profileId, data) {
|
|
||||||
return request(`/api/profiles/${profileId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function changePassword(newPassword) {
|
|
||||||
return request('/api/auth/pin', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ pin: newPassword }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Clubs & Groups
|
// Clubs & Groups
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -879,8 +849,6 @@ export const api = {
|
||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
getCurrentProfile,
|
getCurrentProfile,
|
||||||
updateProfile,
|
|
||||||
changePassword,
|
|
||||||
|
|
||||||
// Clubs & Groups
|
// Clubs & Groups
|
||||||
listClubs,
|
listClubs,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export const BUILD_DATE = "2026-04-23"
|
||||||
export const PAGE_VERSIONS = {
|
export const PAGE_VERSIONS = {
|
||||||
LoginPage: "1.0.0",
|
LoginPage: "1.0.0",
|
||||||
Dashboard: "1.0.0",
|
Dashboard: "1.0.0",
|
||||||
AccountSettingsPage: "1.0.0",
|
ProfilePage: "1.0.0",
|
||||||
ExercisesPage: "1.1.0", // Updated: Katalog-Integration
|
ExercisesPage: "1.1.0", // Updated: Katalog-Integration
|
||||||
ClubsPage: "1.0.0",
|
ClubsPage: "1.0.0",
|
||||||
SkillsPage: "1.0.0",
|
SkillsPage: "1.0.0",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user