From d67026e25a97d5435bf4b5ef61a050ce8aedd52e Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 09:18:22 +0200 Subject: [PATCH 1/4] feat: enhance Nginx configuration and API error handling - Updated Nginx configuration to improve service dependency resolution and proxying for API and media requests. - Added a resolver directive to mitigate sporadic 502 errors related to backend container IP changes. - Enhanced error handling in the API utility to provide clearer feedback for various HTTP errors, including specific guidance for 502 Bad Gateway responses. --- frontend/nginx.conf | 13 ++++++++++--- frontend/src/utils/api.js | 20 ++++++++++++++++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index e226ef6..bf3cec1 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,12 +4,17 @@ server { root /usr/share/nginx/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 — # dafür muss Nginx zur FastAPI-Instanz im Compose-Netz weiterleiten. client_max_body_size 64m; location ^~ /api/ { - proxy_pass http://backend:8000; + set $docker_backend_svc backend; + proxy_pass http://$docker_backend_svc:8000$request_uri; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -21,7 +26,8 @@ server { } location ^~ /media/ { - proxy_pass http://backend:8000; + set $docker_backend_svc backend; + proxy_pass http://$docker_backend_svc:8000$request_uri; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -30,7 +36,8 @@ server { } location = /health { - proxy_pass http://backend:8000/health; + set $hc_upstream backend; + proxy_pass http://$hc_upstream:8000/health; proxy_http_version 1.1; proxy_set_header Host $host; } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 3371692..b91aa3f 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -32,8 +32,24 @@ async function request(endpoint, options = {}) { }) if (!response.ok) { - const error = await response.json().catch(() => ({ detail: 'Unknown error' })) - throw new Error(error.detail || `HTTP ${response.status}`) + const text = await response.text() + let parsed = null + 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() -- 2.43.0 From e6a8931da825979677eeb46f3b79ea3f2d4a7100 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 09:57:36 +0200 Subject: [PATCH 2/4] feat: update environment configuration and enhance development setup - Added optional DEV_APP_URL and DEV_ALLOWED_ORIGINS to docker-compose.dev-env.yml for improved local development flexibility. - Updated .env.example to reflect new environment variables for development. - Modified backend main.py to support rate limiting on authentication routes and improved CORS handling. - Enhanced deploy-dev.yml to include health checks for the frontend service, ensuring better monitoring during development. --- .env.example | 3 +++ .gitea/workflows/deploy-dev.yml | 1 + backend/main.py | 14 ++++++++++++-- docker-compose.dev-env.yml | 11 ++++++++--- docker-compose.yml | 2 +- 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 479a0b7..a5b4153 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,12 @@ SMTP_FROM=noreply@jinkendo.de # App 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 ENVIRONMENT=production +# Nur docker-compose.dev-env.yml (optional): DEV_APP_URL, DEV_ALLOWED_ORIGINS + # Media Storage MEDIA_DIR=/app/media diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml index 63c88ec..ad2ae3d 100644 --- a/.gitea/workflows/deploy-dev.yml +++ b/.gitea/workflows/deploy-dev.yml @@ -19,4 +19,5 @@ jobs: docker compose -f docker-compose.dev-env.yml up -d sleep 5 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 ===" diff --git a/backend/main.py b/backend/main.py index 0949bab..2a9d6ae 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,6 +11,9 @@ from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles 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 # Run database migrations on startup @@ -22,6 +25,8 @@ except Exception as e: print(f"⚠ Warning: Migration error: {e}") print(" Continuing startup - migrations may need manual intervention") +from routers.auth import limiter as auth_rate_limiter + # Initialize FastAPI app app = FastAPI( title="Shinkan Jinkendo API", @@ -29,8 +34,13 @@ app = FastAPI( version=APP_VERSION ) -# CORS Configuration -ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:3098").split(",") +# SlowAPI: Rate Limits auf /api/auth/* (Decorator in routers/auth.py) +app.state.limiter = auth_rate_limiter +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( CORSMiddleware, diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml index 534ca02..b0236cd 100644 --- a/docker-compose.dev-env.yml +++ b/docker-compose.dev-env.yml @@ -34,8 +34,10 @@ services: SMTP_USER: ${SMTP_USER} SMTP_PASS: ${SMTP_PASS} SMTP_FROM: ${SMTP_FROM} - APP_URL: http://192.168.2.49:3098 - ALLOWED_ORIGINS: http://192.168.2.49:3098 + # Öffentliche Dev-URL (E-Mail-Links); lokaler Zugriff per IP bleibt über ALLOWED_ORIGINS möglich + APP_URL: "${DEV_APP_URL:-https://dev.shinkan.jinkendo.de}" + # 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 MEDIAWIKI_API_URL: https://karatetrainer.net/api.php MEDIAWIKI_USER: Jinkendo @@ -58,11 +60,14 @@ services: build: context: ./frontend dockerfile: Dockerfile + # Leer = relative /api/*-URLs → gleicher Host wie die SPA (vermeidet Mixed Content HTTPS→HTTP) args: - VITE_API_URL: http://192.168.2.49:8098 + VITE_API_URL: "" container_name: dev-shinkan-ui ports: - "3098:80" + depends_on: + - backend restart: unless-stopped networks: - dev-shinkan-network diff --git a/docker-compose.yml b/docker-compose.yml index c0ac97c..859a61f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,7 +52,7 @@ services: context: ./frontend dockerfile: Dockerfile args: - VITE_API_URL: https://shinkan.jinkendo.de + VITE_API_URL: "" container_name: shinkan-ui ports: - "3003:80" -- 2.43.0 From d9d2d9e506e74c0d86d872e1bc858e8cc974f5ad Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 10:46:37 +0200 Subject: [PATCH 3/4] fix: correct profile creation logic in registration process - Removed the profile ID from the INSERT statement to align with the database schema, which uses SERIAL for IDs. - Updated comments for clarity regarding the profile creation process and verification status. --- backend/routers/auth.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 166d48d..4c7215b 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -235,18 +235,17 @@ async def register(req: RegisterRequest, request: Request): verification_token = secrets.token_urlsafe(32) verification_expires = datetime.now(timezone.utc) + timedelta(hours=24) - # Create profile (inactive until verified) - profile_id = str(secrets.token_hex(16)) + # Create profile (inactive until verified) — profiles.id ist SERIAL (INT), keine String-IDs einfügen. pin_hash = hash_pin(password) trial_ends = datetime.now(timezone.utc) + timedelta(days=14) # 14-day trial cur.execute(""" INSERT INTO profiles ( - id, name, email, pin_hash, auth_type, role, tier, + name, email, pin_hash, auth_type, role, tier, email_verified, verification_token, verification_expires, trial_ends_at, created_at - ) VALUES (%s, %s, %s, %s, 'email', 'trainer', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP) - """, (profile_id, name, email, pin_hash, verification_token, verification_expires, trial_ends)) + ) VALUES (%s, %s, %s, 'email', 'trainer', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP) + """, (name, email, pin_hash, verification_token, verification_expires, trial_ends)) # Send verification email app_url = os.getenv("APP_URL", "https://mitai.jinkendo.de") -- 2.43.0 From fae673670a8044602a7c3cf5414fa495232d2d3d Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 11:19:05 +0200 Subject: [PATCH 4/4] feat: update authentication and profile management features - Enhanced login response to include additional user information such as email, tier, and role. - Updated profile update logic to restrict access based on user roles and ensure only authorized users can modify profiles. - Replaced ProfilePage with AccountSettingsPage in routing and updated related components to reflect this change. - Added new API functions for updating profiles and changing passwords to improve user account management. --- backend/routers/auth.py | 8 +- backend/routers/profiles.py | 9 +- frontend/src/App.jsx | 5 +- frontend/src/components/DesktopSidebar.jsx | 2 +- frontend/src/context/AuthContext.jsx | 20 +- frontend/src/pages/AccountSettingsPage.jsx | 230 +++++++++++++++++++++ frontend/src/pages/LoginPage.jsx | 4 +- frontend/src/pages/ProfilePage.jsx | 60 ------ frontend/src/utils/api.js | 16 ++ frontend/src/version.js | 2 +- 10 files changed, 283 insertions(+), 73 deletions(-) create mode 100644 frontend/src/pages/AccountSettingsPage.jsx delete mode 100644 frontend/src/pages/ProfilePage.jsx diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 4c7215b..da49987 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -53,9 +53,11 @@ async def login(req: LoginRequest, request: Request): return { "token": token, "profile_id": prof['id'], - "name": prof['name'], - "role": prof['role'], - "expires_at": expires.isoformat() + "email": prof.get("email"), + "name": prof.get("name"), + "role": prof.get("role"), + "tier": prof.get("tier"), + "expires_at": expires.isoformat(), } diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py index 6bff787..c79b185 100644 --- a/backend/routers/profiles.py +++ b/backend/routers/profiles.py @@ -82,7 +82,12 @@ def get_profile(pid: str, session=Depends(require_auth)): @router.put("/profiles/{pid}") def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): - """Update profile by ID.""" + """Update profile — nur eigenes Profil oder Admin.""" + 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: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) @@ -134,7 +139,7 @@ def update_profile(pid: str, p: ProfileUpdate, session=Depends(require_auth)): if not data: return get_profile(pid, session) - data["updated"] = datetime.now().isoformat() + data["updated_at"] = datetime.now() cols = ", ".join(f"{k}=%s" for k in data) vals = list(data.values()) + [pid] cur.execute(f"UPDATE profiles SET {cols} WHERE id=%s", vals) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 560f396..1f42013 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,7 +13,7 @@ import DesktopSidebar from './components/DesktopSidebar' import { getMainNavItems } from './config/appNav' import LoginPage from './pages/LoginPage' import Dashboard from './pages/Dashboard' -import ProfilePage from './pages/ProfilePage' +import AccountSettingsPage from './pages/AccountSettingsPage' import ExercisesListPage from './pages/ExercisesListPage' import ExerciseDetailPage from './pages/ExerciseDetailPage' import ExerciseFormPage from './pages/ExerciseFormPage' @@ -144,7 +144,8 @@ function AppRoutes() { }> } /> - } /> + } /> + } /> } /> } /> diff --git a/frontend/src/components/DesktopSidebar.jsx b/frontend/src/components/DesktopSidebar.jsx index 4642c43..b95be31 100644 --- a/frontend/src/components/DesktopSidebar.jsx +++ b/frontend/src/components/DesktopSidebar.jsx @@ -61,7 +61,7 @@ export default function DesktopSidebar({ fontSize: '14px' }} > - {user?.name?.charAt(0) || 'U'} + {(user?.name || user?.email || '?').trim().slice(0, 1).toUpperCase()}
diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 89eea51..0015b28 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -29,8 +29,24 @@ export function AuthProvider({ children }) { } } - const login = (data) => { - setUser(data.profile || data) + /** Fallback, falls ohne checkAuth gesetzt wird (Legacy / Token-Injektion) */ + const login = (payload) => { + 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 = () => { diff --git a/frontend/src/pages/AccountSettingsPage.jsx b/frontend/src/pages/AccountSettingsPage.jsx new file mode 100644 index 0000000..b4044f6 --- /dev/null +++ b/frontend/src/pages/AccountSettingsPage.jsx @@ -0,0 +1,230 @@ +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 ( +
+

Einstellungen

+

+ Konto & Sicherheit +

+ + {message && ( +
+ {message} +
+ )} + {error && ( +
+ {error} +
+ )} + +
+

Profil

+
+ E-Mail +
+ {user?.email || '—'}{' '} + {verified ? ( + + bestätigt + + ) : ( + + noch nicht bestätigt + + )} +
+ +
+ + setName(e.target.value)} + placeholder="Dein Name in der App" + autoComplete="nickname" + /> + +
+
+ +
+

Rollen & Tarif

+
+ Rolle + {user?.role === 'admin' ? 'Administrator' : user?.role || 'trainer'} + + Tier + + {user?.tier || 'free'} + +
+
+ +
+

Passwort ändern

+

+ Wähle ein neues Passwort (mindestens 4 Zeichen, wie beim Login gewohnt empfehlen wir längere Passwörter). +

+
+
+ + setNewPw1(e.target.value)} + autoComplete="new-password" + minLength={4} + /> +
+
+ + setNewPw2(e.target.value)} + autoComplete="new-password" + /> +
+ +
+
+
+ ) +} + +export default AccountSettingsPage diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 8c8effc..5318609 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -13,7 +13,7 @@ function LoginPage() { const [success, setSuccess] = useState('') const navigate = useNavigate() - const { login: authLogin } = useAuth() + const { checkAuth } = useAuth() const handleSubmit = async (e) => { e.preventDefault() @@ -25,7 +25,7 @@ function LoginPage() { if (mode === 'login') { const response = await api.login(email, password) localStorage.setItem('authToken', response.token) - authLogin({ token: response.token, profile: response.profile }) + await checkAuth() navigate('/') } else { await api.register(email, password, name) diff --git a/frontend/src/pages/ProfilePage.jsx b/frontend/src/pages/ProfilePage.jsx deleted file mode 100644 index 889c341..0000000 --- a/frontend/src/pages/ProfilePage.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useAuth } from '../context/AuthContext' - -function ProfilePage() { - const { user } = useAuth() - - return ( -
-
-

Profil

- -
-

Persönliche Daten

- -
-
- Name: - {user?.name || '-'} - - E-Mail: - {user?.email} - - Rolle: - - {user?.role || 'user'} - - - Tier: - - {user?.tier || 'free'} - -
-
-
- -
-

Einstellungen

-

- Bearbeitung folgt in Kürze -

-
-
-
- ) -} - -export default ProfilePage diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index b91aa3f..7e5281c 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -93,6 +93,20 @@ export async function getCurrentProfile() { 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 // ============================================================================ @@ -865,6 +879,8 @@ export const api = { register, logout, getCurrentProfile, + updateProfile, + changePassword, // Clubs & Groups listClubs, diff --git a/frontend/src/version.js b/frontend/src/version.js index 301e0f7..ca6abef 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -6,7 +6,7 @@ export const BUILD_DATE = "2026-04-23" export const PAGE_VERSIONS = { LoginPage: "1.0.0", Dashboard: "1.0.0", - ProfilePage: "1.0.0", + AccountSettingsPage: "1.0.0", ExercisesPage: "1.1.0", // Updated: Katalog-Integration ClubsPage: "1.0.0", SkillsPage: "1.0.0", -- 2.43.0