Merge pull request 'Security Update 1' (#59) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 55s
All checks were successful
Deploy Production / deploy (push) Successful in 55s
Reviewed-on: #59
This commit is contained in:
commit
4e5a7b466f
9
.claude/docs/technical/DESIGN_PRINCIPLES_RELOCATED.md
Normal file
9
.claude/docs/technical/DESIGN_PRINCIPLES_RELOCATED.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Designprinzipien — verschoben
|
||||
|
||||
Die **Jinkendo-Produktfamilie Designprinzipien** (Index + Shinkan-Serie) liegen nicht mehr hier.
|
||||
|
||||
**Neuer Standort (Shinkan-Serie):** [docs/jinkendo-family/design-principles/DESIGN_PRINCIPLES_INDEX.md](../../../docs/jinkendo-family/design-principles/DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
||||
**Mitai-Serie:** [mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/](file:///c:/Dev/mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md)
|
||||
|
||||
**Abgleich:** [docs/jinkendo-family/DESIGN_PRINCIPLES_ALIGNMENT.md](../../../docs/jinkendo-family/DESIGN_PRINCIPLES_ALIGNMENT.md)
|
||||
|
|
@ -7,6 +7,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| profiles | `GET /api/profiles/me` | ja | `resolve_tenant_context` inline (`invalid_header_policy=ignore`) | teils | + `effective_club_id`; veralteter Header bricht Refresh nicht |
|
||||
| profiles | `GET /api/profiles`, `GET /profiles/{pid}`, `POST /profiles`, `DELETE /profiles/{pid}` | ja/teils | `require_auth` | ja | Liste nur Plattform-Admin; GET nach ID eigenes Profil oder Admin; POST/DELETE nur Admin |
|
||||
| profiles | `PUT /api/profiles/{id}`, `PUT /api/profile` | ja | `get_tenant_context` | `active_club_id` Mitgliedschaft | Validiert `X-Active-Club-Id` konsistent zu Mitgliedschaft |
|
||||
| profiles | `POST /api/profiles/{id}/totp-reset` | Plattform | `get_tenant_context` | nur `superadmin`, nicht das eigene Konto | Schaltet TOTP ab, löscht Wiederherstellungscodes, beendet Sitzungen |
|
||||
| clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth |
|
||||
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
|
||||
| club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | |
|
||||
|
|
|
|||
|
|
@ -53,7 +53,12 @@ SMTP_FROM=noreply@jinkendo.de
|
|||
SMTP_SSL=
|
||||
SMTP_STARTTLS=
|
||||
|
||||
AUTO_ADMIN_FIRST_USER=true
|
||||
# Produktion: false. true nur für eine bewusst leere Erstinstallation.
|
||||
AUTO_ADMIN_FIRST_USER=false
|
||||
# true erzwingt TOTP für Plattform-Admins beim Login. Standard ist freiwillig.
|
||||
REQUIRE_ADMIN_TOTP=false
|
||||
# Optional. Sonst wird das Medien-Token aus DB_PASSWORD abgeleitet.
|
||||
# MEDIA_TOKEN_SECRET=
|
||||
ADMIN_BOOTSTRAP_EMAILS=
|
||||
|
||||
APP_URL=https://shinkan.jinkendo.de
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ AUTH_INFRA_PREFIXES = (
|
|||
"/api/auth/status",
|
||||
"/api/auth/pin",
|
||||
"/api/auth/resend-verification",
|
||||
"/api/auth/totp",
|
||||
"/api/auth/media-grant",
|
||||
"/api/profiles/me",
|
||||
"/api/me/entitlements",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ from fastapi import Header, Query, HTTPException
|
|||
import bcrypt
|
||||
|
||||
from db import get_db, get_cursor
|
||||
from media_grant import profile_id_from_media_grant
|
||||
|
||||
# Fester bcrypt-Hash, damit ein unbekannter Login dieselbe Rechenzeit hat wie ein Treffer.
|
||||
_LOGIN_TIMING_HASH = "$2b$12$GYdnAmeiqaZg/.5J9E4f4.xhz70piViqXIpqKCY/FY609qj65sc9i"
|
||||
MAX_SESSION_DAYS = 30
|
||||
|
||||
|
||||
def hash_pin(pin: str) -> str:
|
||||
|
|
@ -33,6 +38,34 @@ def verify_pin(pin: str, stored_hash: str) -> bool:
|
|||
return stored_hash == hashlib.sha256(pin.encode()).hexdigest()
|
||||
|
||||
|
||||
def verify_pin_for_login(pin: str, stored_hash: Optional[str]) -> bool:
|
||||
"""Immer ein bcrypt-Vergleich, auch wenn das Konto fehlt (kein Zeit-Orakel)."""
|
||||
if stored_hash and str(stored_hash).startswith("$2"):
|
||||
return verify_pin(pin, stored_hash)
|
||||
verify_pin(pin or "x", _LOGIN_TIMING_HASH)
|
||||
if not stored_hash:
|
||||
return False
|
||||
return verify_pin(pin, stored_hash)
|
||||
|
||||
|
||||
def session_lifetime_days(profile: Optional[dict]) -> int:
|
||||
raw = profile.get("session_days") if profile else None
|
||||
try:
|
||||
days = int(raw) if raw is not None else MAX_SESSION_DAYS
|
||||
except (TypeError, ValueError):
|
||||
days = MAX_SESSION_DAYS
|
||||
if days < 1:
|
||||
days = 1
|
||||
if days > MAX_SESSION_DAYS:
|
||||
days = MAX_SESSION_DAYS
|
||||
return days
|
||||
|
||||
|
||||
def revoke_profile_sessions(cur, profile_id: int) -> None:
|
||||
"""Alle Sitzungen eines Profils entfernen, inklusive Reset- und 2FA-Zwischentokens."""
|
||||
cur.execute("DELETE FROM sessions WHERE profile_id = %s", (profile_id,))
|
||||
|
||||
|
||||
def make_token() -> str:
|
||||
"""Generate a secure random token for sessions."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
|
@ -51,12 +84,39 @@ def get_session(token: str):
|
|||
cur.execute(
|
||||
"SELECT s.*, p.role, p.name "
|
||||
"FROM sessions s JOIN profiles p ON s.profile_id=p.id "
|
||||
"WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP",
|
||||
"WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP "
|
||||
"AND COALESCE(s.purpose, 'full') = 'full'",
|
||||
(token,)
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def get_limited_session(token: str, purpose: str):
|
||||
"""Sitzung mit festem Zweck (totp_enroll, totp_challenge). Kein Fach-API-Zugriff."""
|
||||
if not token or purpose not in ("totp_enroll", "totp_challenge"):
|
||||
return None
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT s.*, p.role, p.name, p.email "
|
||||
"FROM sessions s JOIN profiles p ON s.profile_id=p.id "
|
||||
"WHERE s.token=%s AND s.expires_at > CURRENT_TIMESTAMP "
|
||||
"AND s.purpose = %s",
|
||||
(token, purpose),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def _profile_session_from_id(profile_id: int) -> Optional[dict]:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT id AS profile_id, role, name FROM profiles WHERE id = %s",
|
||||
(profile_id,),
|
||||
)
|
||||
return cur.fetchone()
|
||||
|
||||
|
||||
def require_auth(x_auth_token: Optional[str] = Header(default=None)):
|
||||
"""
|
||||
FastAPI dependency - requires valid authentication.
|
||||
|
|
@ -80,8 +140,8 @@ def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ss
|
|||
"""
|
||||
FastAPI dependency - auth via header OR query parameter.
|
||||
|
||||
Used for endpoints accessed by <img> tags and SSE connections that can't send headers.
|
||||
Query parameter is 'ssetoken' to avoid conflicts with endpoint 'token' parameters.
|
||||
Dateiabruf für <img>/<video>: Header-Sitzung oder kurzlebiges Medien-Token (?ssetoken).
|
||||
Ein Sitzungstoken in der Query-Zeichenfolge wird nicht akzeptiert.
|
||||
|
||||
Usage:
|
||||
@app.get("/api/photos/{id}")
|
||||
|
|
@ -93,7 +153,13 @@ def require_auth_flexible(x_auth_token: Optional[str] = Header(default=None), ss
|
|||
Raises:
|
||||
HTTPException 401 if not authenticated
|
||||
"""
|
||||
session = get_session(x_auth_token or ssetoken)
|
||||
session = get_session(x_auth_token) if x_auth_token else None
|
||||
if session:
|
||||
return session
|
||||
profile_id = profile_id_from_media_grant(ssetoken)
|
||||
if profile_id is None:
|
||||
raise HTTPException(401, "Nicht eingeloggt")
|
||||
session = _profile_session_from_id(profile_id)
|
||||
if not session:
|
||||
raise HTTPException(401, "Nicht eingeloggt")
|
||||
return session
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
from typing import FrozenSet, Optional, Set
|
||||
from html import escape as html_escape
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
|
@ -40,7 +42,126 @@ def normalize_inline_exercise_media_markup(html: Optional[str]) -> Optional[str]
|
|||
f'class="shinkan-inline-media"></span>'
|
||||
)
|
||||
|
||||
return _BRACE_PATTERN.sub(_repl, html)
|
||||
return sanitize_exercise_rich_html(_BRACE_PATTERN.sub(_repl, html))
|
||||
|
||||
|
||||
_ALLOWED_RICH_TAGS = frozenset(
|
||||
{"p", "div", "br", "ul", "ol", "li", "h3", "b", "strong", "i", "em", "u", "span", "a"}
|
||||
)
|
||||
_DROP_WITH_CHILDREN = frozenset(
|
||||
{"script", "iframe", "object", "embed", "style", "link", "meta", "svg", "math"}
|
||||
)
|
||||
_VOID_TAGS = frozenset({"br"})
|
||||
_MEDIA_SIZES = frozenset({"small", "medium", "full"})
|
||||
_CAPTION_MAX = 120
|
||||
|
||||
|
||||
def _clean_caption(raw: str) -> str:
|
||||
text = re.sub(r"[\u0000-\u001f\u007f]", " ", raw or "")
|
||||
text = re.sub(r"[\"'`<>]", " ", text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
return text[:_CAPTION_MAX]
|
||||
|
||||
|
||||
def _safe_rich_attrs(tag: str, attrs: list[tuple[str, Optional[str]]]) -> list[tuple[str, str]]:
|
||||
values = {}
|
||||
for name, value in attrs:
|
||||
if not name:
|
||||
continue
|
||||
values[name.lower()] = "" if value is None else str(value)
|
||||
if tag == "a":
|
||||
href = values.get("href", "").strip()
|
||||
if href.lower().startswith(("http://", "https://")) and not re.search(r"[\u0000-\u001f\u007f]", href):
|
||||
return [("href", href)]
|
||||
return []
|
||||
if tag != "span":
|
||||
return []
|
||||
media_id = values.get("data-shinkan-exercise-media", "").strip()
|
||||
if not re.fullmatch(r"\d+", media_id):
|
||||
return []
|
||||
out = [("data-shinkan-exercise-media", media_id)]
|
||||
size = values.get("data-shinkan-exercise-media-size", "").strip().lower()
|
||||
if size in _MEDIA_SIZES:
|
||||
out.append(("data-shinkan-exercise-media-size", size))
|
||||
caption = _clean_caption(values.get("data-shinkan-exercise-media-caption", ""))
|
||||
if caption:
|
||||
out.append(("data-shinkan-exercise-media-caption", caption))
|
||||
classes = [c for c in values.get("class", "").split() if c == "shinkan-inline-media"]
|
||||
if classes:
|
||||
out.append(("class", "shinkan-inline-media"))
|
||||
return out
|
||||
|
||||
|
||||
class _RichHtmlSanitizer(HTMLParser):
|
||||
"""Baut erlaubtes Übungs-HTML neu auf. Unbekannte Tags fallen weg, ihr Text bleibt."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts: list[str] = []
|
||||
self._drop_depth = 0
|
||||
self._stack: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
|
||||
self._start(tag.lower(), attrs, void=False)
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
|
||||
self._start(tag.lower(), attrs, void=True)
|
||||
|
||||
def _start(self, tag: str, attrs: list[tuple[str, Optional[str]]], *, void: bool) -> None:
|
||||
if self._drop_depth:
|
||||
if not void and tag not in _VOID_TAGS:
|
||||
self._drop_depth += 1
|
||||
return
|
||||
if tag in _DROP_WITH_CHILDREN:
|
||||
if not void:
|
||||
self._drop_depth = 1
|
||||
return
|
||||
if tag not in _ALLOWED_RICH_TAGS:
|
||||
return
|
||||
safe = _safe_rich_attrs(tag, attrs)
|
||||
rendered = tag
|
||||
if safe:
|
||||
rendered += "".join(f' {name}="{html_escape(value, quote=True)}"' for name, value in safe)
|
||||
if void or tag in _VOID_TAGS:
|
||||
self.parts.append(f"<{rendered}>")
|
||||
return
|
||||
self._stack.append(tag)
|
||||
self.parts.append(f"<{rendered}>")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
tag = tag.lower()
|
||||
if self._drop_depth:
|
||||
if tag in _DROP_WITH_CHILDREN or self._drop_depth > 1:
|
||||
self._drop_depth = max(0, self._drop_depth - 1)
|
||||
return
|
||||
if self._stack and self._stack[-1] == tag:
|
||||
self._stack.pop()
|
||||
self.parts.append(f"</{tag}>")
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._drop_depth or not data:
|
||||
return
|
||||
self.parts.append(html_escape(data))
|
||||
|
||||
def handle_entityref(self, name: str) -> None:
|
||||
self.handle_data(f"&{name};")
|
||||
|
||||
def handle_charref(self, name: str) -> None:
|
||||
self.handle_data(f"&#{name};")
|
||||
|
||||
|
||||
def sanitize_exercise_rich_html(html: Optional[str]) -> Optional[str]:
|
||||
"""Allowlist für gespeichertes Übungs-HTML. Skripte, Handler und fremde Attribute fallen weg."""
|
||||
if html is None:
|
||||
return None
|
||||
if not isinstance(html, str):
|
||||
html = str(html)
|
||||
if not html.strip():
|
||||
return html
|
||||
parser = _RichHtmlSanitizer()
|
||||
parser.feed(html)
|
||||
parser.close()
|
||||
return "".join(parser.parts)
|
||||
|
||||
|
||||
def collect_inline_exercise_media_ids(html: Optional[str]) -> Set[int]:
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ app.include_router(ai_skill_retrieval_admin.router)
|
|||
app.include_router(exercise_enrichment_admin.router)
|
||||
|
||||
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
||||
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
||||
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken = kurzlebiges Medien-Token, kein Sitzungstoken).
|
||||
# Notfall/Legacy: ALLOW_PUBLIC_MEDIA_STATIC=1 → wieder öffentlich unter /media/
|
||||
_media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))
|
||||
Path(_media_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
|
|
|||
49
backend/media_grant.py
Normal file
49
backend/media_grant.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Kurzlebige Medien-Tokens. Kein Sitzungstoken — nur für Dateiabruf per Query."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
MEDIA_GRANT_TTL_SECONDS = 600
|
||||
_PREFIX = "mg1"
|
||||
|
||||
|
||||
def _key() -> bytes:
|
||||
dedicated = (os.getenv("MEDIA_TOKEN_SECRET") or "").strip()
|
||||
if dedicated:
|
||||
return hashlib.sha256(dedicated.encode("utf-8")).digest()
|
||||
material = (os.getenv("DB_PASSWORD") or "shinkan-dev-media").encode("utf-8")
|
||||
return hashlib.sha256(b"shinkan-media-grant-v1|" + material).digest()
|
||||
|
||||
|
||||
def issue_media_grant(profile_id: int, *, ttl_seconds: int = MEDIA_GRANT_TTL_SECONDS) -> tuple[str, int]:
|
||||
exp = int(time.time()) + int(ttl_seconds)
|
||||
payload = f"{int(profile_id)}.{exp}"
|
||||
sig = hmac.new(_key(), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
return f"{_PREFIX}.{payload}.{sig}", exp
|
||||
|
||||
|
||||
def profile_id_from_media_grant(token: Optional[str], *, now: Optional[int] = None) -> Optional[int]:
|
||||
if not token or not token.startswith(f"{_PREFIX}."):
|
||||
return None
|
||||
parts = token.split(".")
|
||||
if len(parts) != 4:
|
||||
return None
|
||||
_, pid_s, exp_s, sig = parts
|
||||
if not pid_s.isdigit() or not exp_s.isdigit():
|
||||
return None
|
||||
payload = f"{pid_s}.{exp_s}"
|
||||
expected = hmac.new(_key(), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, sig):
|
||||
return None
|
||||
exp = int(exp_s)
|
||||
moment = int(time.time() if now is None else now)
|
||||
if exp < moment:
|
||||
return None
|
||||
pid = int(pid_s)
|
||||
if pid < 1:
|
||||
return None
|
||||
return pid
|
||||
29
backend/migrations/096_login_lockout_and_totp.sql
Normal file
29
backend/migrations/096_login_lockout_and_totp.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
-- Login-Sperre, Sitzungszweck (Vollzugriff / 2FA-Einrichtung / 2FA-Challenge), TOTP.
|
||||
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS failed_login_count INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS locked_until TIMESTAMP,
|
||||
ADD COLUMN IF NOT EXISTS totp_secret TEXT,
|
||||
ADD COLUMN IF NOT EXISTS totp_pending_secret TEXT,
|
||||
ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN IF NOT EXISTS purpose VARCHAR(32) NOT NULL DEFAULT 'full',
|
||||
ADD COLUMN IF NOT EXISTS failed_attempts INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE sessions
|
||||
SET purpose = 'password_reset'
|
||||
WHERE token LIKE 'reset_%' AND purpose = 'full';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_profile_purpose ON sessions (profile_id, purpose);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profile_recovery_codes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
code_hash VARCHAR(64) NOT NULL,
|
||||
used_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profile_recovery_codes_profile
|
||||
ON profile_recovery_codes (profile_id);
|
||||
|
|
@ -13,16 +13,38 @@ from datetime import date, time, datetime
|
|||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class LoginTotpRequest(BaseModel):
|
||||
challenge_token: str = Field(min_length=8, max_length=200)
|
||||
code: str = Field(min_length=6, max_length=32)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
current_password: str = Field(min_length=1, max_length=128)
|
||||
new_password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class TotpCodeRequest(BaseModel):
|
||||
code: str = Field(min_length=6, max_length=12)
|
||||
|
||||
|
||||
class TotpDisableRequest(BaseModel):
|
||||
current_password: str = Field(min_length=1, max_length=128)
|
||||
code: str = Field(min_length=6, max_length=32)
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
name: Optional[str] = None
|
||||
requested_club_id: Optional[int] = Field(default=None, ge=1)
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: str
|
||||
email: EmailStr
|
||||
|
||||
class ResendVerificationRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ def insert_password_reset_session(cur: Any, profile_id: int, *, hours_valid: int
|
|||
expires = datetime.now() + timedelta(hours=hours_valid)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sessions (token, profile_id, expires_at, created_at)
|
||||
VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
|
||||
INSERT INTO sessions (token, profile_id, expires_at, created_at, purpose)
|
||||
VALUES (%s, %s, %s, CURRENT_TIMESTAMP, 'password_reset')
|
||||
""",
|
||||
(f"{RESET_TOKEN_PREFIX}{raw}", profile_id, expires.isoformat()),
|
||||
)
|
||||
|
|
@ -53,6 +53,7 @@ Neues Passwort setzen:
|
|||
|
||||
Der Link ist 1 Stunde gültig. Erst wenn du ihn nutzt und ein neues Passwort wählst, wird dein bestehendes
|
||||
Passwort ersetzt — bis dahin kannst du dich wie gewohnt anmelden.
|
||||
Der Link meldet dich nicht an und schaltet die Zwei-Faktor-Anmeldung nicht ab.
|
||||
|
||||
Falls du diese Anfrage nicht erwartest, ignoriere diese E-Mail; dein Zugang bleibt unverändert.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ from planning_exercise_form_context import (
|
|||
prior_path_steps_before_major,
|
||||
)
|
||||
from planning_exercise_semantics import PlanningSemanticBrief, brief_to_summary_dict
|
||||
from planning_catalog_context import ProgressionPlanningCatalogContext
|
||||
from planning_prompt_variables import catalog_exercise_hints_block
|
||||
|
||||
_logger = logging.getLogger("shinkan.planning_exercise_path_ai_fill")
|
||||
|
||||
|
|
@ -54,6 +56,7 @@ def _build_stage_ai_context(
|
|||
step_after: Optional[Mapping[str, Any]] = None,
|
||||
prior_steps: Optional[Sequence[Mapping[str, Any]]] = None,
|
||||
start_situation: Optional[str] = None,
|
||||
catalog_exercise_hints: Optional[str] = None,
|
||||
) -> ExerciseFormAiPromptContext:
|
||||
"""KI-Kontext für unbesetzte Roadmap-Stufe (keine Brücke zwischen falschen Array-Indizes)."""
|
||||
gap = dict(spec.get("gap") or {})
|
||||
|
|
@ -97,6 +100,9 @@ def _build_stage_ai_context(
|
|||
sketch = (spec.get("sketch") or "").strip()
|
||||
if sketch and sketch != learning_goal:
|
||||
goal_parts.extend(["", f"Kontext: {sketch}"])
|
||||
hints = (catalog_exercise_hints or "").strip()
|
||||
if hints:
|
||||
goal_parts.extend(["", "Katalog-Hinweise Übungsanlage:", hints])
|
||||
goal = "\n".join(goal_parts)
|
||||
|
||||
focus_hint = topic if brief.topic_type == "technique" else None
|
||||
|
|
@ -118,6 +124,7 @@ def try_suggest_ai_stage_step(
|
|||
brief: PlanningSemanticBrief,
|
||||
spec: Mapping[str, Any],
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""KI-Vorschlag für leere Roadmap-Stufe."""
|
||||
major_idx = spec.get("roadmap_major_step_index")
|
||||
|
|
@ -136,6 +143,8 @@ def try_suggest_ai_stage_step(
|
|||
if not gap.get("learning_goal"):
|
||||
gap["learning_goal"] = spec.get("title_hint") or spec.get("sketch")
|
||||
|
||||
exercise_hints = catalog_exercise_hints_block(cur, catalog) if catalog else ""
|
||||
|
||||
ctx = _build_stage_ai_context(
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
|
|
@ -143,6 +152,7 @@ def try_suggest_ai_stage_step(
|
|||
step_before=step_before,
|
||||
step_after=step_after,
|
||||
prior_steps=prior_steps,
|
||||
catalog_exercise_hints=exercise_hints or None,
|
||||
)
|
||||
try:
|
||||
ai_payload = run_exercise_form_ai_suggestion(cur, ctx=ctx)
|
||||
|
|
@ -189,6 +199,7 @@ def _build_gap_ai_context(
|
|||
gap: Mapping[str, Any],
|
||||
title_hint: Optional[str] = None,
|
||||
sketch_hint: Optional[str] = None,
|
||||
catalog_exercise_hints: Optional[str] = None,
|
||||
) -> ExerciseFormAiPromptContext:
|
||||
topic = (brief.primary_topic or "Technik").strip()
|
||||
phase = gap.get("expected_phase") or "vertiefung"
|
||||
|
|
@ -206,6 +217,9 @@ def _build_gap_ai_context(
|
|||
]
|
||||
if sketch:
|
||||
goal_parts.extend(["", f"Hinweis: {sketch}"])
|
||||
hints = (catalog_exercise_hints or "").strip()
|
||||
if hints:
|
||||
goal_parts.extend(["", "Katalog-Hinweise Übungsanlage:", hints])
|
||||
goal = "\n".join(goal_parts)
|
||||
|
||||
focus_hint = topic if brief.topic_type == "technique" else None
|
||||
|
|
@ -271,8 +285,10 @@ def try_suggest_ai_bridge_step(
|
|||
gap: Mapping[str, Any],
|
||||
title_hint: Optional[str] = None,
|
||||
sketch_hint: Optional[str] = None,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Ruft exercise AI suggest auf — kein Speichern in DB."""
|
||||
exercise_hints = catalog_exercise_hints_block(cur, catalog) if catalog else ""
|
||||
ctx = _build_gap_ai_context(
|
||||
goal_query=goal_query,
|
||||
brief=brief,
|
||||
|
|
@ -281,6 +297,7 @@ def try_suggest_ai_bridge_step(
|
|||
gap=gap,
|
||||
title_hint=title_hint,
|
||||
sketch_hint=sketch_hint,
|
||||
catalog_exercise_hints=exercise_hints or None,
|
||||
)
|
||||
g_plain = strip_html_to_plain(ctx.goal)
|
||||
if not g_plain.strip() and not (ctx.title or "").strip():
|
||||
|
|
@ -478,6 +495,7 @@ def build_gap_fill_goal_text(
|
|||
step_a: Optional[Mapping[str, Any]] = None,
|
||||
step_b: Optional[Mapping[str, Any]] = None,
|
||||
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||
catalog_exercise_hints: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Ausführlicher Zieltext für KI-Neuanlage aus Pfad-, Roadmap- und Stufen-Kontext."""
|
||||
topic = (brief.primary_topic or "Technik").strip()
|
||||
|
|
@ -555,6 +573,9 @@ def build_gap_fill_goal_text(
|
|||
parts.append(f"Qualitätsprüfung: {spec['rationale']}")
|
||||
if spec.get("sketch"):
|
||||
parts.append(f"Skizze: {spec['sketch']}")
|
||||
hints = (catalog_exercise_hints or "").strip()
|
||||
if hints:
|
||||
parts.append(f"Katalog-Hinweise Übungsanlage:\n{hints}")
|
||||
parts.append(
|
||||
"Die Übung muss die Stufe didaktisch erfüllen: klare Voraussetzungen, messbares Stufenziel, "
|
||||
"Bezug zum Gesamtpfad — keine generische Kraftübung ohne Technikbezug. "
|
||||
|
|
@ -571,6 +592,7 @@ def build_gap_fill_offer(
|
|||
brief: Optional[PlanningSemanticBrief] = None,
|
||||
proposal: Optional[Mapping[str, Any]] = None,
|
||||
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||
catalog_exercise_hints: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
source = spec.get("source")
|
||||
idx = int(spec.get("insert_after_index") or 0)
|
||||
|
|
@ -603,6 +625,7 @@ def build_gap_fill_offer(
|
|||
step_a=step_a,
|
||||
step_b=step_b,
|
||||
roadmap_snapshot=enriched_snapshot or None,
|
||||
catalog_exercise_hints=catalog_exercise_hints,
|
||||
)
|
||||
ctx_preview = enriched_snapshot or None
|
||||
offer: Dict[str, Any] = {
|
||||
|
|
@ -642,6 +665,7 @@ def apply_gap_fill_after_qa(
|
|||
max_ai_proposals: int = 3,
|
||||
auto_insert_proposals: bool = False,
|
||||
roadmap_snapshot: Optional[Mapping[str, Any]] = None,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Erzeugt gap_fill_offers für die UI; optional KI-Vorschläge einfügen.
|
||||
|
|
@ -653,6 +677,7 @@ def apply_gap_fill_after_qa(
|
|||
out = list(steps)
|
||||
proposals: List[Dict[str, Any]] = []
|
||||
offers: List[Dict[str, Any]] = []
|
||||
exercise_hints = catalog_exercise_hints_block(cur, catalog) if catalog else ""
|
||||
|
||||
for spec in specs:
|
||||
source = spec.get("source")
|
||||
|
|
@ -666,6 +691,7 @@ def apply_gap_fill_after_qa(
|
|||
brief=brief,
|
||||
spec=spec,
|
||||
steps=out,
|
||||
catalog=catalog,
|
||||
)
|
||||
offer = build_gap_fill_offer(
|
||||
spec=spec,
|
||||
|
|
@ -674,6 +700,7 @@ def apply_gap_fill_after_qa(
|
|||
brief=brief,
|
||||
proposal=proposal,
|
||||
roadmap_snapshot=roadmap_snapshot,
|
||||
catalog_exercise_hints=exercise_hints or None,
|
||||
)
|
||||
offers.append(offer)
|
||||
if proposal and auto_insert_proposals:
|
||||
|
|
@ -700,6 +727,7 @@ def apply_gap_fill_after_qa(
|
|||
brief=brief,
|
||||
proposal=None,
|
||||
roadmap_snapshot=roadmap_snapshot,
|
||||
catalog_exercise_hints=exercise_hints or None,
|
||||
)
|
||||
offers.append(offer)
|
||||
continue
|
||||
|
|
@ -719,6 +747,7 @@ def apply_gap_fill_after_qa(
|
|||
gap=gap,
|
||||
title_hint=str(spec.get("title_hint") or ""),
|
||||
sketch_hint=str(spec.get("sketch") or ""),
|
||||
catalog=catalog,
|
||||
)
|
||||
|
||||
offer = build_gap_fill_offer(
|
||||
|
|
@ -728,6 +757,7 @@ def apply_gap_fill_after_qa(
|
|||
brief=brief,
|
||||
proposal=proposal,
|
||||
roadmap_snapshot=roadmap_snapshot,
|
||||
catalog_exercise_hints=exercise_hints or None,
|
||||
)
|
||||
offers.append(offer)
|
||||
|
||||
|
|
|
|||
|
|
@ -24,9 +24,11 @@ from planning_catalog_context import (
|
|||
load_catalog_context_from_graph_row,
|
||||
merge_catalog_context_into_target,
|
||||
)
|
||||
from catalog_prompt_slots import get_rematch_guard_for_catalog
|
||||
from planning_exercise_profiles import PlanningTargetProfile
|
||||
from planning_path_qa_pipeline import run_multistage_path_qa
|
||||
from planning_path_rematch import (
|
||||
apply_rematch_catalog_guard,
|
||||
collect_rematch_slot_indices,
|
||||
filter_rematch_slot_indices,
|
||||
prune_stripped_after_rematch,
|
||||
|
|
@ -1737,6 +1739,13 @@ def _run_roadmap_rematch_loop(
|
|||
stripped_off_topic=current_stripped if round_idx == 0 else [],
|
||||
off_topic_steps=off_topic_before_strip if round_idx == 0 and use_initial_off_topic else [],
|
||||
)
|
||||
catalog_context = _resolve_planning_catalog_context(cur, body)
|
||||
rematch_guard = get_rematch_guard_for_catalog(cur, catalog_context)
|
||||
slot_indices = apply_rematch_catalog_guard(
|
||||
slot_indices,
|
||||
rematch_reasons,
|
||||
rematch_guard=rematch_guard,
|
||||
)
|
||||
if not slot_indices:
|
||||
break
|
||||
|
||||
|
|
@ -2191,6 +2200,7 @@ def _run_evaluate_only_path_qa(
|
|||
max_ai_proposals=0,
|
||||
auto_insert_proposals=False,
|
||||
roadmap_snapshot=path_roadmap_snapshot,
|
||||
catalog=catalog_context,
|
||||
)
|
||||
|
||||
multistage_qa = run_multistage_path_qa(
|
||||
|
|
@ -4294,6 +4304,7 @@ def suggest_progression_path(
|
|||
max_ai_proposals=0,
|
||||
auto_insert_proposals=False,
|
||||
roadmap_snapshot=path_roadmap_snapshot,
|
||||
catalog=catalog_context,
|
||||
)
|
||||
|
||||
if roadmap_gap_offers:
|
||||
|
|
|
|||
|
|
@ -49,9 +49,10 @@ class PlanningIntentContext:
|
|||
context_notes: str = ""
|
||||
topic_type: str = "general"
|
||||
technique_sibling_excludes: List[str] = field(default_factory=list)
|
||||
catalog_exercise_hints: Optional[str] = None
|
||||
|
||||
def to_api_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
out = {
|
||||
"source_query": self.source_query,
|
||||
"primary_topic": self.primary_topic,
|
||||
"topic_type": self.topic_type,
|
||||
|
|
@ -61,6 +62,10 @@ class PlanningIntentContext:
|
|||
"technique_sibling_excludes": self.technique_sibling_excludes[:16],
|
||||
"context_notes": self.context_notes[:1200] or None,
|
||||
}
|
||||
hints = (self.catalog_exercise_hints or "").strip()
|
||||
if hints:
|
||||
out["catalog_exercise_hints"] = hints[:2000]
|
||||
return out
|
||||
|
||||
|
||||
def build_planning_intent_context(
|
||||
|
|
@ -70,6 +75,8 @@ def build_planning_intent_context(
|
|||
goal_analysis: Optional[Mapping[str, Any]] = None,
|
||||
extra_context: Optional[str] = None,
|
||||
primary_topic: Optional[str] = None,
|
||||
cur=None,
|
||||
catalog=None,
|
||||
) -> PlanningIntentContext:
|
||||
"""Intent aus Anfrage, Zielanalyse und optionalem Kontext — ohne Sonderregeln pro Thema."""
|
||||
ga = dict(goal_analysis or {})
|
||||
|
|
@ -124,6 +131,12 @@ def build_planning_intent_context(
|
|||
if line not in path_success:
|
||||
path_success.insert(0, line)
|
||||
|
||||
catalog_hints = ""
|
||||
if cur is not None and catalog is not None:
|
||||
from planning_prompt_variables import catalog_exercise_hints_block
|
||||
|
||||
catalog_hints = catalog_exercise_hints_block(cur, catalog)
|
||||
|
||||
return PlanningIntentContext(
|
||||
source_query=(goal_query or "").strip(),
|
||||
primary_topic=topic,
|
||||
|
|
@ -133,6 +146,7 @@ def build_planning_intent_context(
|
|||
explicit_exclusions=explicit,
|
||||
technique_sibling_excludes=siblings[:16],
|
||||
context_notes=combined_notes[:1200],
|
||||
catalog_exercise_hints=catalog_hints or None,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence, Set, Tuple
|
|||
|
||||
from planning_progression_roadmap import ProgressionRoadmapContext, StageSpecArtifact
|
||||
|
||||
_UNFILLED_REMATCH_REASON = "Keine passende Übung für Roadmap-Stufe"
|
||||
|
||||
|
||||
def _slot_priority_for_rematch(
|
||||
body,
|
||||
|
|
@ -103,7 +105,7 @@ def collect_rematch_slot_indices(
|
|||
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
idx, spec = item[0], item[1]
|
||||
midx = getattr(spec, "major_step_index", idx)
|
||||
_register(int(midx), "Keine passende Übung für Roadmap-Stufe")
|
||||
_register(int(midx), _UNFILLED_REMATCH_REASON)
|
||||
elif isinstance(item, dict):
|
||||
midx = _resolve_major(item)
|
||||
if midx is not None:
|
||||
|
|
@ -114,6 +116,25 @@ def collect_rematch_slot_indices(
|
|||
return indices, reasons
|
||||
|
||||
|
||||
def apply_rematch_catalog_guard(
|
||||
slot_indices: Set[int],
|
||||
rematch_reasons: Mapping[int, str],
|
||||
*,
|
||||
rematch_guard: Optional[str],
|
||||
) -> Set[int]:
|
||||
"""
|
||||
Bei gesetztem rematch_guard (Katalog-Slot): kein Auto-Rematch nur wegen leerer Roadmap-Stufe.
|
||||
Off-Topic, explizite QS-Hinweise und Stufen-Verfeinerung bleiben aktiv.
|
||||
"""
|
||||
if not (rematch_guard or "").strip():
|
||||
return set(slot_indices)
|
||||
return {
|
||||
idx
|
||||
for idx in slot_indices
|
||||
if str(rematch_reasons.get(idx) or "").strip() != _UNFILLED_REMATCH_REASON
|
||||
}
|
||||
|
||||
|
||||
def filter_rematch_slot_indices(
|
||||
steps: Sequence[Mapping[str, Any]],
|
||||
slot_indices: Set[int],
|
||||
|
|
@ -372,6 +393,7 @@ def prune_stripped_after_rematch(
|
|||
|
||||
|
||||
__all__ = [
|
||||
"apply_rematch_catalog_guard",
|
||||
"collect_rematch_slot_indices",
|
||||
"filter_rematch_slot_indices",
|
||||
"prune_stripped_after_rematch",
|
||||
|
|
|
|||
|
|
@ -1244,6 +1244,8 @@ def run_progression_roadmap_pipeline(
|
|||
resolved.target_state,
|
||||
),
|
||||
primary_topic=goal_analysis.primary_topic,
|
||||
cur=cur,
|
||||
catalog=catalog,
|
||||
)
|
||||
|
||||
heuristic_specs = build_stage_specs(
|
||||
|
|
|
|||
|
|
@ -34,6 +34,27 @@ _PLANNING_PROMPT_VARIABLE_PROVIDERS: tuple[PlanningPromptVariableProvider, ...]
|
|||
)
|
||||
|
||||
|
||||
def catalog_exercise_hints_block(
|
||||
cur,
|
||||
catalog: Optional[ProgressionPlanningCatalogContext] = None,
|
||||
*,
|
||||
slug: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Kombinierte hints_on_exercise aus aktivem Katalog-Kontext (Gap-Fill / Intent)."""
|
||||
if cur is None or catalog is None:
|
||||
return ""
|
||||
from catalog_prompt_slots import CATALOG_KINDS, placeholder_key
|
||||
|
||||
merged = merge_planning_prompt_variables(cur, {}, catalog=catalog, slug=slug)
|
||||
lines: list[str] = []
|
||||
for cfg in CATALOG_KINDS:
|
||||
key = placeholder_key(cfg.kind, "hints_on_exercise")
|
||||
text = (merged.get(key) or "").strip()
|
||||
if text:
|
||||
lines.append(f"{cfg.label_de}: {text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def merge_planning_prompt_variables(
|
||||
cur,
|
||||
base_variables: Mapping[str, str],
|
||||
|
|
@ -113,6 +134,7 @@ def planning_prompt_placeholder_catalog() -> dict:
|
|||
|
||||
|
||||
__all__ = [
|
||||
"catalog_exercise_hints_block",
|
||||
"merge_planning_prompt_variables",
|
||||
"planning_prompt_placeholder_catalog",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ router = APIRouter(prefix="/api/admin", tags=["admin_users"])
|
|||
|
||||
_SAFE_PROFILE_COLS = """
|
||||
id, name, email, role, tier, email_verified, active_club_id,
|
||||
created_at, updated_at, auth_type
|
||||
created_at, updated_at, auth_type, totp_enabled
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -14,12 +14,41 @@ from email.mime.text import MIMEText
|
|||
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||
from starlette.requests import Request
|
||||
import ipaddress
|
||||
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from db import get_db, get_cursor
|
||||
from auth import hash_pin, verify_pin, make_token, require_auth
|
||||
from models import LoginRequest, PasswordResetRequest, PasswordResetConfirm, RegisterRequest
|
||||
from auth import (
|
||||
get_limited_session,
|
||||
get_session,
|
||||
hash_pin,
|
||||
make_token,
|
||||
require_auth,
|
||||
revoke_profile_sessions,
|
||||
session_lifetime_days,
|
||||
verify_pin,
|
||||
verify_pin_for_login,
|
||||
)
|
||||
from media_grant import MEDIA_GRANT_TTL_SECONDS, issue_media_grant
|
||||
from models import (
|
||||
ChangePasswordRequest,
|
||||
LoginRequest,
|
||||
LoginTotpRequest,
|
||||
PasswordResetRequest,
|
||||
PasswordResetConfirm,
|
||||
RegisterRequest,
|
||||
ResendVerificationRequest,
|
||||
TotpCodeRequest,
|
||||
TotpDisableRequest,
|
||||
)
|
||||
from totp_auth import (
|
||||
generate_recovery_codes,
|
||||
generate_totp_secret,
|
||||
hash_recovery_code,
|
||||
otpauth_uri,
|
||||
verify_totp,
|
||||
)
|
||||
from password_reset_mail import (
|
||||
insert_password_reset_session,
|
||||
password_reset_email_body,
|
||||
|
|
@ -27,47 +56,158 @@ from password_reset_mail import (
|
|||
)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
LOGIN_LOCK_AFTER = 8
|
||||
LOGIN_LOCK_MINUTES = 15
|
||||
_ADMIN_ROLES = ("admin", "superadmin")
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
@limiter.limit("30/minute")
|
||||
async def login(req: LoginRequest, request: Request):
|
||||
"""Login with email + password."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM profiles WHERE email=%s", (req.email.lower().strip(),))
|
||||
prof = cur.fetchone()
|
||||
if not prof:
|
||||
raise HTTPException(401, "Ungültige Zugangsdaten")
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""Echte Client-IP nur, wenn der direkte Peer ein vertrauenswürdiger Proxy ist."""
|
||||
peer = request.client.host if request.client and request.client.host else ""
|
||||
if _is_trusted_proxy(peer):
|
||||
real = (request.headers.get("x-real-ip") or "").split(",")[0].strip()
|
||||
if real:
|
||||
return real[:64]
|
||||
return peer or "unknown"
|
||||
|
||||
# Verify password
|
||||
if not verify_pin(req.password, prof['pin_hash']):
|
||||
raise HTTPException(401, "Ungültige Zugangsdaten")
|
||||
|
||||
# Auto-upgrade from SHA256 to bcrypt
|
||||
if prof['pin_hash'] and not prof['pin_hash'].startswith('$2'):
|
||||
new_hash = hash_pin(req.password)
|
||||
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, prof['id']))
|
||||
def _is_trusted_proxy(host: str) -> bool:
|
||||
if not host:
|
||||
return False
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return False
|
||||
return ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
|
||||
# Create session
|
||||
token = make_token()
|
||||
session_days = prof.get('session_days', 30)
|
||||
expires = datetime.now() + timedelta(days=session_days)
|
||||
cur.execute("INSERT INTO sessions (token, profile_id, expires_at, created_at) VALUES (%s,%s,%s,CURRENT_TIMESTAMP)",
|
||||
(token, prof['id'], expires.isoformat()))
|
||||
|
||||
limiter = Limiter(key_func=_client_ip)
|
||||
|
||||
|
||||
def _admin_totp_required() -> bool:
|
||||
"""Nur wenn REQUIRE_ADMIN_TOTP ausdrücklich an ist. Standard: der Nutzer entscheidet selbst."""
|
||||
raw = (os.getenv("REQUIRE_ADMIN_TOTP") or "").strip().lower()
|
||||
return raw in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _issue_session(cur, profile_id: int, *, days: int, purpose: str = "full") -> tuple[str, datetime]:
|
||||
token = make_token()
|
||||
expires = datetime.now(timezone.utc) + timedelta(days=days if purpose == "full" else 0, minutes=0)
|
||||
if purpose == "totp_challenge":
|
||||
expires = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||
elif purpose == "totp_enroll":
|
||||
expires = datetime.now(timezone.utc) + timedelta(minutes=30)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sessions (token, profile_id, expires_at, created_at, purpose)
|
||||
VALUES (%s, %s, %s, CURRENT_TIMESTAMP, %s)
|
||||
""",
|
||||
(token, profile_id, expires, purpose),
|
||||
)
|
||||
return token, expires
|
||||
|
||||
|
||||
def _login_payload(prof: dict, token: str, expires: datetime) -> dict:
|
||||
return {
|
||||
"token": token,
|
||||
"profile_id": prof['id'],
|
||||
"profile_id": prof["id"],
|
||||
"email": prof.get("email"),
|
||||
"name": prof.get("name"),
|
||||
"role": prof.get("role"),
|
||||
"tier": prof.get("tier"),
|
||||
"expires_at": expires.isoformat(),
|
||||
"totp_enabled": bool(prof.get("totp_enabled")),
|
||||
}
|
||||
|
||||
|
||||
def _record_failed_login(cur, profile_id: int) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE profiles
|
||||
SET failed_login_count = CASE
|
||||
WHEN failed_login_count + 1 >= %s THEN 0
|
||||
ELSE failed_login_count + 1
|
||||
END,
|
||||
locked_until = CASE
|
||||
WHEN failed_login_count + 1 >= %s
|
||||
THEN CURRENT_TIMESTAMP + (%s * INTERVAL '1 minute')
|
||||
ELSE locked_until
|
||||
END
|
||||
WHERE id = %s
|
||||
""",
|
||||
(LOGIN_LOCK_AFTER, LOGIN_LOCK_AFTER, LOGIN_LOCK_MINUTES, profile_id),
|
||||
)
|
||||
|
||||
|
||||
def _clear_login_lock(cur, profile_id: int) -> None:
|
||||
cur.execute(
|
||||
"UPDATE profiles SET failed_login_count = 0, locked_until = NULL WHERE id = %s",
|
||||
(profile_id,),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
@limiter.limit("10/minute")
|
||||
async def login(req: LoginRequest, request: Request):
|
||||
"""Login with email + password. Bei aktivem TOTP folgt ein zweiter Schritt."""
|
||||
email = req.email.lower().strip()
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT *,
|
||||
(locked_until IS NOT NULL AND locked_until > CURRENT_TIMESTAMP) AS is_locked
|
||||
FROM profiles WHERE email=%s
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
prof = cur.fetchone()
|
||||
password_ok = verify_pin_for_login(req.password, prof["pin_hash"] if prof else None)
|
||||
if not prof or not password_ok:
|
||||
if prof and not prof.get("is_locked"):
|
||||
_record_failed_login(cur, prof["id"])
|
||||
outcome = "invalid"
|
||||
elif prof.get("is_locked"):
|
||||
outcome = "locked"
|
||||
else:
|
||||
outcome = "ok"
|
||||
if prof["pin_hash"] and not str(prof["pin_hash"]).startswith("$2"):
|
||||
cur.execute(
|
||||
"UPDATE profiles SET pin_hash=%s WHERE id=%s",
|
||||
(hash_pin(req.password), prof["id"]),
|
||||
)
|
||||
_clear_login_lock(cur, prof["id"])
|
||||
|
||||
if outcome == "invalid":
|
||||
raise HTTPException(401, "Ungültige Zugangsdaten")
|
||||
if outcome == "locked":
|
||||
raise HTTPException(429, "Zu viele Anmeldeversuche. Bitte später erneut versuchen.")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
role = (prof.get("role") or "").strip().lower()
|
||||
if prof.get("totp_enabled"):
|
||||
token, expires = _issue_session(cur, prof["id"], days=1, purpose="totp_challenge")
|
||||
return {
|
||||
"totp_required": True,
|
||||
"challenge_token": token,
|
||||
"expires_at": expires.isoformat(),
|
||||
}
|
||||
if role in _ADMIN_ROLES and _admin_totp_required() and not prof.get("totp_enabled"):
|
||||
token, expires = _issue_session(cur, prof["id"], days=1, purpose="totp_enroll")
|
||||
return {
|
||||
"totp_enrollment_required": True,
|
||||
"token": token,
|
||||
"email": prof.get("email"),
|
||||
"expires_at": expires.isoformat(),
|
||||
}
|
||||
|
||||
token, expires = _issue_session(cur, prof["id"], days=session_lifetime_days(prof))
|
||||
|
||||
return _login_payload(prof, token, expires)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(x_auth_token: Optional[str]=Header(default=None)):
|
||||
"""Logout (delete session)."""
|
||||
|
|
@ -94,21 +234,246 @@ def auth_status():
|
|||
|
||||
|
||||
@router.put("/pin")
|
||||
def change_pin(req: dict, session: dict=Depends(require_auth)):
|
||||
"""Change PIN/password for current user."""
|
||||
pid = session['profile_id']
|
||||
new_pin = req.get('pin', '')
|
||||
if len(new_pin) < 8:
|
||||
raise HTTPException(400, "Passwort muss mind. 8 Zeichen haben")
|
||||
|
||||
new_hash = hash_pin(new_pin)
|
||||
def change_pin(req: ChangePasswordRequest, session: dict = Depends(require_auth)):
|
||||
"""Passwort wechseln. Das aktuelle Passwort ist Pflicht; alle Sitzungen werden ersetzt."""
|
||||
pid = session["profile_id"]
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
prof = cur.fetchone()
|
||||
if not prof or not verify_pin(req.current_password, prof["pin_hash"]):
|
||||
raise HTTPException(400, "Aktuelles Passwort ist falsch")
|
||||
cur.execute(
|
||||
"UPDATE profiles SET pin_hash=%s WHERE id=%s",
|
||||
(hash_pin(req.new_password), pid),
|
||||
)
|
||||
revoke_profile_sessions(cur, int(pid))
|
||||
token, expires = _issue_session(cur, int(pid), days=session_lifetime_days(prof))
|
||||
return {"ok": True, "token": token, "expires_at": expires.isoformat()}
|
||||
|
||||
|
||||
@router.post("/login/totp")
|
||||
@limiter.limit("10/minute")
|
||||
async def login_totp(req: LoginTotpRequest, request: Request):
|
||||
"""Zweiter Login-Schritt: Authenticator-Code oder Wiederherstellungscode."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.*, p.role, p.name, p.email
|
||||
FROM sessions s JOIN profiles p ON s.profile_id = p.id
|
||||
WHERE s.token = %s AND s.expires_at > CURRENT_TIMESTAMP AND s.purpose = 'totp_challenge'
|
||||
""",
|
||||
(req.challenge_token,),
|
||||
)
|
||||
challenge = cur.fetchone()
|
||||
error = None
|
||||
payload = None
|
||||
if not challenge:
|
||||
error = "Anmeldung abgelaufen. Bitte erneut anmelden."
|
||||
else:
|
||||
pid = int(challenge["profile_id"])
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
prof = cur.fetchone()
|
||||
if not prof or not prof.get("totp_enabled"):
|
||||
error = "Anmeldung abgelaufen. Bitte erneut anmelden."
|
||||
else:
|
||||
code_ok = verify_totp(prof.get("totp_secret"), req.code) or _consume_recovery_code(
|
||||
cur, pid, req.code
|
||||
)
|
||||
if not code_ok:
|
||||
attempts = int(challenge.get("failed_attempts") or 0) + 1
|
||||
if attempts >= 5:
|
||||
cur.execute("DELETE FROM sessions WHERE token=%s", (req.challenge_token,))
|
||||
else:
|
||||
cur.execute(
|
||||
"UPDATE sessions SET failed_attempts=%s WHERE token=%s",
|
||||
(attempts, req.challenge_token),
|
||||
)
|
||||
error = "Ungültiger Code"
|
||||
else:
|
||||
revoke_profile_sessions(cur, pid)
|
||||
token, expires = _issue_session(cur, pid, days=session_lifetime_days(prof))
|
||||
payload = _login_payload(prof, token, expires)
|
||||
if error:
|
||||
raise HTTPException(401, error)
|
||||
return payload
|
||||
|
||||
|
||||
def _consume_recovery_code(cur, profile_id: int, code: str) -> bool:
|
||||
digest = hash_recovery_code(code)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE profile_recovery_codes
|
||||
SET used_at = CURRENT_TIMESTAMP
|
||||
WHERE profile_id = %s AND code_hash = %s AND used_at IS NULL
|
||||
RETURNING id
|
||||
""",
|
||||
(profile_id, digest),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _setup_session_or_401(token: str) -> dict:
|
||||
full = None
|
||||
if token:
|
||||
full = get_session(token)
|
||||
if full:
|
||||
return full
|
||||
enroll = get_limited_session(token, "totp_enroll") if token else None
|
||||
if not enroll:
|
||||
raise HTTPException(401, "Nicht eingeloggt")
|
||||
return enroll
|
||||
|
||||
|
||||
@router.get("/totp/status")
|
||||
def totp_status(x_auth_token: Optional[str] = Header(default=None)):
|
||||
session = _setup_session_or_401(x_auth_token or "")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT email, role, totp_enabled FROM profiles WHERE id=%s",
|
||||
(session["profile_id"],),
|
||||
)
|
||||
prof = cur.fetchone()
|
||||
if not prof:
|
||||
raise HTTPException(401, "Nicht eingeloggt")
|
||||
role = (prof.get("role") or session.get("role") or "").strip().lower()
|
||||
enrollment = (session.get("purpose") or "full") == "totp_enroll"
|
||||
return {
|
||||
"totp_enabled": bool(prof.get("totp_enabled")),
|
||||
"enrollment_required": enrollment or (
|
||||
role in _ADMIN_ROLES and _admin_totp_required() and not prof.get("totp_enabled")
|
||||
),
|
||||
"email": prof.get("email"),
|
||||
"role": role,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/totp/setup")
|
||||
def totp_setup(x_auth_token: Optional[str] = Header(default=None)):
|
||||
"""Neues Geheimnis erzeugen. Wirksam erst nach /totp/confirm."""
|
||||
session = _setup_session_or_401(x_auth_token or "")
|
||||
secret = generate_totp_secret()
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT email FROM profiles WHERE id=%s", (session["profile_id"],))
|
||||
prof = cur.fetchone()
|
||||
if not prof:
|
||||
raise HTTPException(404, "Profil nicht gefunden")
|
||||
cur.execute(
|
||||
"UPDATE profiles SET totp_pending_secret=%s WHERE id=%s",
|
||||
(secret, session["profile_id"]),
|
||||
)
|
||||
email = prof.get("email") or "konto"
|
||||
return {
|
||||
"secret": secret,
|
||||
"otpauth_uri": otpauth_uri(secret=secret, account_name=email),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/totp/confirm")
|
||||
def totp_confirm(req: TotpCodeRequest, x_auth_token: Optional[str] = Header(default=None)):
|
||||
session = _setup_session_or_401(x_auth_token or "")
|
||||
pid = int(session["profile_id"])
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
prof = cur.fetchone()
|
||||
if not prof or not prof.get("totp_pending_secret"):
|
||||
raise HTTPException(400, "Zuerst ein neues Geheimnis erzeugen")
|
||||
if not verify_totp(prof.get("totp_pending_secret"), req.code):
|
||||
raise HTTPException(400, "Code stimmt nicht. Uhrzeit des Geräts prüfen.")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE profiles
|
||||
SET totp_secret=%s, totp_pending_secret=NULL, totp_enabled=TRUE
|
||||
WHERE id=%s
|
||||
""",
|
||||
(prof["totp_pending_secret"], pid),
|
||||
)
|
||||
plain_codes = _replace_recovery_codes(cur, pid)
|
||||
prof = dict(prof)
|
||||
prof["totp_enabled"] = True
|
||||
prof["totp_secret"] = None
|
||||
revoke_profile_sessions(cur, pid)
|
||||
token, expires = _issue_session(cur, pid, days=session_lifetime_days(prof))
|
||||
payload = _login_payload(prof, token, expires)
|
||||
payload["recovery_codes"] = plain_codes
|
||||
return payload
|
||||
|
||||
|
||||
def _replace_recovery_codes(cur, profile_id: int) -> list[str]:
|
||||
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (profile_id,))
|
||||
plain_codes = generate_recovery_codes()
|
||||
for code in plain_codes:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO profile_recovery_codes (profile_id, code_hash)
|
||||
VALUES (%s, %s)
|
||||
""",
|
||||
(profile_id, hash_recovery_code(code)),
|
||||
)
|
||||
return plain_codes
|
||||
|
||||
|
||||
@router.post("/totp/recovery-codes")
|
||||
def regenerate_recovery_codes(req: TotpDisableRequest, session: dict = Depends(require_auth)):
|
||||
"""Neue Codes ausgeben. Die bisherigen werden ungültig. Passwort und aktueller App-Code sind Pflicht."""
|
||||
pid = int(session["profile_id"])
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
prof = cur.fetchone()
|
||||
if not prof or not prof.get("totp_enabled"):
|
||||
raise HTTPException(400, "Zwei-Faktor ist nicht aktiv")
|
||||
if not verify_pin(req.current_password, prof["pin_hash"]):
|
||||
raise HTTPException(400, "Aktuelles Passwort ist falsch")
|
||||
if not verify_totp(prof.get("totp_secret"), req.code):
|
||||
raise HTTPException(400, "Code aus der App stimmt nicht")
|
||||
plain_codes = _replace_recovery_codes(cur, pid)
|
||||
return {"recovery_codes": plain_codes}
|
||||
|
||||
|
||||
@router.post("/totp/disable")
|
||||
def totp_disable(req: TotpDisableRequest, session: dict = Depends(require_auth)):
|
||||
pid = int(session["profile_id"])
|
||||
role = (session.get("role") or "").strip().lower()
|
||||
if role in _ADMIN_ROLES and _admin_totp_required():
|
||||
raise HTTPException(400, "Für Plattform-Admins bleibt die Zwei-Faktor-Anmeldung aktiv.")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
prof = cur.fetchone()
|
||||
if not prof or not verify_pin(req.current_password, prof["pin_hash"]):
|
||||
raise HTTPException(400, "Aktuelles Passwort ist falsch")
|
||||
if not (
|
||||
verify_totp(prof.get("totp_secret"), req.code)
|
||||
or _consume_recovery_code(cur, pid, req.code)
|
||||
):
|
||||
raise HTTPException(400, "Code stimmt nicht")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE profiles
|
||||
SET totp_enabled=FALSE, totp_secret=NULL, totp_pending_secret=NULL
|
||||
WHERE id=%s
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (pid,))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/media-grant")
|
||||
def media_grant(session: dict = Depends(require_auth)):
|
||||
"""Kurzlebiges Token nur für Medien-Dateien. Ersetzt das Sitzungstoken in Bild-URLs."""
|
||||
token, exp = issue_media_grant(int(session["profile_id"]), ttl_seconds=MEDIA_GRANT_TTL_SECONDS)
|
||||
return {
|
||||
"token": token,
|
||||
"expires_at": datetime.fromtimestamp(exp, tz=timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
@limiter.limit("3/minute")
|
||||
async def password_reset_request(req: PasswordResetRequest, request: Request):
|
||||
|
|
@ -146,10 +511,17 @@ def password_reset_confirm(req: PasswordResetConfirm):
|
|||
if not sess:
|
||||
raise HTTPException(400, "Ungültiger oder abgelaufener Reset-Link")
|
||||
|
||||
pid = sess['profile_id']
|
||||
pid = sess["profile_id"]
|
||||
new_hash = hash_pin(req.new_password)
|
||||
cur.execute("UPDATE profiles SET pin_hash=%s WHERE id=%s", (new_hash, pid))
|
||||
cur.execute("DELETE FROM sessions WHERE token=%s", (f"reset_{req.token}",))
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE profiles
|
||||
SET pin_hash=%s, failed_login_count=0, locked_until=NULL
|
||||
WHERE id=%s
|
||||
""",
|
||||
(new_hash, pid),
|
||||
)
|
||||
revoke_profile_sessions(cur, int(pid))
|
||||
|
||||
return {"ok": True, "message": "Passwort erfolgreich zurückgesetzt"}
|
||||
|
||||
|
|
@ -272,55 +644,76 @@ async def register(req: RegisterRequest, request: Request):
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Check if email already exists
|
||||
cur.execute("SELECT id FROM profiles WHERE email=%s", (email,))
|
||||
if cur.fetchone():
|
||||
raise HTTPException(400, "E-Mail-Adresse bereits registriert")
|
||||
cur.execute("SELECT id, name FROM profiles WHERE email=%s", (email,))
|
||||
existing = cur.fetchone()
|
||||
if existing:
|
||||
existing_name = existing.get("name")
|
||||
else:
|
||||
existing_name = None
|
||||
|
||||
# Generate verification token
|
||||
verification_token = secrets.token_urlsafe(32)
|
||||
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
if existing is None:
|
||||
# Generate verification token
|
||||
verification_token = secrets.token_urlsafe(32)
|
||||
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
|
||||
# Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → superadmin
|
||||
role = registration_role(cur, email)
|
||||
# Rolle: erster Nutzer oder ADMIN_BOOTSTRAP_EMAILS → superadmin
|
||||
role = registration_role(cur, email)
|
||||
|
||||
# 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
|
||||
# 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 (
|
||||
name, email, pin_hash, auth_type, role, tier,
|
||||
email_verified, verification_token, verification_expires,
|
||||
trial_ends_at, created_at
|
||||
) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
""", (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
|
||||
new_profile_id = cur.fetchone()["id"]
|
||||
cur.execute("""
|
||||
INSERT INTO profiles (
|
||||
name, email, pin_hash, auth_type, role, tier,
|
||||
email_verified, verification_token, verification_expires,
|
||||
trial_ends_at, created_at
|
||||
) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
""", (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
|
||||
new_profile_id = cur.fetchone()["id"]
|
||||
|
||||
req_club = req.requested_club_id
|
||||
if req_club is not None:
|
||||
cur.execute(
|
||||
"SELECT id FROM clubs WHERE id = %s AND status = 'active'",
|
||||
(int(req_club),),
|
||||
)
|
||||
if cur.fetchone():
|
||||
req_club = req.requested_club_id
|
||||
if req_club is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM club_membership_requests
|
||||
WHERE profile_id = %s AND club_id = %s AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(new_profile_id, int(req_club)),
|
||||
"SELECT id FROM clubs WHERE id = %s AND status = 'active'",
|
||||
(int(req_club),),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
if cur.fetchone():
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_membership_requests (profile_id, club_id, status, message)
|
||||
VALUES (%s, %s, 'pending', NULL)
|
||||
SELECT id FROM club_membership_requests
|
||||
WHERE profile_id = %s AND club_id = %s AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(new_profile_id, int(req_club)),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO club_membership_requests (profile_id, club_id, status, message)
|
||||
VALUES (%s, %s, 'pending', NULL)
|
||||
""",
|
||||
(new_profile_id, int(req_club)),
|
||||
)
|
||||
|
||||
generic = {
|
||||
"ok": True,
|
||||
"message": "Registrierung erfolgreich! Bitte prüfe dein E-Mail-Postfach und bestätige deine E-Mail-Adresse.",
|
||||
}
|
||||
if existing is not None:
|
||||
send_email(
|
||||
email,
|
||||
"Shinkan Jinkendo – Registrierung",
|
||||
f"""Hallo {existing_name or name},
|
||||
|
||||
für diese E-Mail-Adresse gibt es bereits ein Konto. Es wurde kein neues Passwort gesetzt.
|
||||
Wenn du den Zugang brauchst, melde dich an oder fordere ein neues Passwort an.
|
||||
|
||||
Dein Shinkan Jinkendo Team
|
||||
""",
|
||||
)
|
||||
return generic
|
||||
|
||||
verify_url = verification_link(verification_token)
|
||||
|
||||
|
|
@ -340,10 +733,7 @@ Dein Shinkan Jinkendo Team
|
|||
if not send_email(email, "Shinkan Jinkendo – E-Mail bestätigen", email_body):
|
||||
print("[SMTP] Verifizierungs-Mail konnte nicht gesendet werden — Logs prüfen, SMTP_* in der Laufzeitumgebung.")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "Registrierung erfolgreich! Bitte prüfe dein E-Mail-Postfach und bestätige deine E-Mail-Adresse."
|
||||
}
|
||||
return generic
|
||||
|
||||
|
||||
@router.get("/verify/{token}")
|
||||
|
|
@ -402,44 +792,34 @@ async def verify_email(token: str):
|
|||
|
||||
@router.post("/resend-verification")
|
||||
@limiter.limit("3/hour")
|
||||
async def resend_verification(req: dict, request: Request):
|
||||
"""Resend verification email for unverified account."""
|
||||
email = req.get('email', '').strip().lower()
|
||||
|
||||
if not email:
|
||||
raise HTTPException(400, "E-Mail-Adresse erforderlich")
|
||||
async def resend_verification(req: ResendVerificationRequest, request: Request):
|
||||
"""Bestätigung erneut senden. Bekannte und unbekannte Adressen antworten gleich."""
|
||||
email = str(req.email).lower().strip()
|
||||
generic = {
|
||||
"ok": True,
|
||||
"message": "Falls ein Account mit dieser E-Mail existiert, wurde eine Bestätigungs-E-Mail versendet.",
|
||||
}
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Find profile by email
|
||||
cur.execute("""
|
||||
SELECT id, name, email, email_verified, verification_token, verification_expires
|
||||
FROM profiles
|
||||
WHERE email=%s
|
||||
""", (email,))
|
||||
|
||||
prof = cur.fetchone()
|
||||
if not prof or prof["email_verified"]:
|
||||
return generic
|
||||
|
||||
if not prof:
|
||||
# Don't leak info about existing emails
|
||||
return {"ok": True, "message": "Falls ein Account mit dieser E-Mail existiert, wurde eine Bestätigungs-E-Mail versendet."}
|
||||
|
||||
if prof['email_verified']:
|
||||
raise HTTPException(400, "E-Mail-Adresse bereits bestätigt")
|
||||
|
||||
# Generate new verification token
|
||||
verification_token = secrets.token_urlsafe(32)
|
||||
verification_expires = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
|
||||
cur.execute("""
|
||||
UPDATE profiles
|
||||
SET verification_token=%s, verification_expires=%s
|
||||
WHERE id=%s
|
||||
""", (verification_token, verification_expires, prof['id']))
|
||||
""", (verification_token, verification_expires, prof["id"]))
|
||||
|
||||
verify_url = verification_link(verification_token)
|
||||
|
||||
email_body = f"""Hallo {prof['name']},
|
||||
|
||||
du hast eine neue Bestätigungs-E-Mail angefordert.
|
||||
|
|
@ -452,8 +832,5 @@ Dieser Link ist 24 Stunden gültig.
|
|||
|
||||
Dein Shinkan Jinkendo Team
|
||||
"""
|
||||
|
||||
if not send_email(email, "Shinkan Jinkendo – E-Mail bestätigen", email_body):
|
||||
raise HTTPException(502, "E-Mail konnte nicht versendet werden. SMTP-Einstellungen und Container-Logs prüfen.")
|
||||
|
||||
return {"ok": True, "message": "Bestätigungs-E-Mail wurde erneut versendet."}
|
||||
send_email(email, "Shinkan Jinkendo – E-Mail bestätigen", email_body)
|
||||
return generic
|
||||
|
|
|
|||
|
|
@ -28,11 +28,12 @@ import os
|
|||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from club_tenancy import is_platform_admin, is_superadmin
|
||||
from db import get_db, get_cursor, r2d
|
||||
from routers.auth import limiter
|
||||
from media_legal_hold import (
|
||||
LEGAL_HOLD_REASON_CODES,
|
||||
assert_superadmin_for_legal_hold,
|
||||
|
|
@ -324,6 +325,7 @@ def _resolve_optional_auth(cur, x_auth_token: Optional[str]) -> tuple[Optional[i
|
|||
INNER JOIN profiles p ON p.id = s.profile_id
|
||||
WHERE s.token = %s
|
||||
AND (s.expires_at IS NULL OR s.expires_at > NOW())
|
||||
AND COALESCE(s.purpose, 'full') = 'full'
|
||||
""",
|
||||
(x_auth_token,),
|
||||
)
|
||||
|
|
@ -431,8 +433,17 @@ def _report_row_to_dict(row) -> dict:
|
|||
|
||||
# ─── Endpoints ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _content_report_rate_marker(request: Request) -> None:
|
||||
"""SlowAPI-Ziel für 10 Meldungen pro Stunde. Nicht als Route: der Decorator bricht die Annotationen."""
|
||||
return None
|
||||
|
||||
|
||||
limiter.limit("10/hour")(_content_report_rate_marker)
|
||||
|
||||
|
||||
@router.post("/content-reports", status_code=201)
|
||||
def submit_content_report(
|
||||
request: Request,
|
||||
body: ContentReportCreate,
|
||||
x_auth_token: Optional[str] = Header(default=None),
|
||||
):
|
||||
|
|
@ -445,6 +456,7 @@ def submit_content_report(
|
|||
- Journaleintrag im Medien-Audit-Log (wenn target_type=media_asset)
|
||||
- E-Mail-Bestaetigung an Melder + Benachrichtigung an Plattform-Admins (best-effort)
|
||||
"""
|
||||
limiter._check_request_limit(request, _content_report_rate_marker, False)
|
||||
if not body.good_faith_confirmed:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
|
|||
|
|
@ -163,24 +163,9 @@ ALLOWED_UPLOAD_MIMES = frozenset(
|
|||
}
|
||||
)
|
||||
|
||||
# Dateiendung → MIME wenn der Client keinen sinnvollen Content-Type sendet (häufig mobil / iOS).
|
||||
_UPLOAD_FILENAME_MIME_FALLBACK = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".pdf": "application/pdf",
|
||||
}
|
||||
|
||||
|
||||
def _sniff_allowed_upload_mime(raw: bytes) -> Optional[str]:
|
||||
"""Erkennt erlaubte MIME-Typen anhand weniger Magic Bytes (ohne Pillow/python-magic)."""
|
||||
if len(raw) < 12:
|
||||
return None
|
||||
if raw[:3] == b"\xff\xd8\xff":
|
||||
return "image/jpeg"
|
||||
if raw[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
|
|
@ -189,7 +174,7 @@ def _sniff_allowed_upload_mime(raw: bytes) -> Optional[str]:
|
|||
return "image/gif"
|
||||
if raw[:4] == b"%PDF":
|
||||
return "application/pdf"
|
||||
if raw[4:8] != b"ftyp":
|
||||
if len(raw) < 12 or raw[4:8] != b"ftyp":
|
||||
return None
|
||||
brand = raw[8:12]
|
||||
# HEIC / HEIF (u. a. iPhone „高效率“)
|
||||
|
|
@ -208,17 +193,11 @@ def resolve_upload_mime_type(
|
|||
content_type: Optional[str],
|
||||
filename: Optional[str],
|
||||
) -> str:
|
||||
"""Ermittelt ein erlaubtes MIME (Client-Header, Magic Bytes oder Dateiendung)."""
|
||||
ct = (content_type or "").split(";")[0].strip().lower()
|
||||
if ct in ALLOWED_UPLOAD_MIMES:
|
||||
return ct
|
||||
"""MIME nur aus dem Dateiinhalt. Client-Header und Dateiendung entscheiden nicht."""
|
||||
guessed = _sniff_allowed_upload_mime(raw)
|
||||
if guessed in ALLOWED_UPLOAD_MIMES:
|
||||
return guessed
|
||||
ext = Path(filename or "").suffix.lower()
|
||||
fb = _UPLOAD_FILENAME_MIME_FALLBACK.get(ext)
|
||||
if fb in ALLOWED_UPLOAD_MIMES:
|
||||
return fb
|
||||
ct = (content_type or "").split(";")[0].strip().lower()
|
||||
raise ValueError(f"Dateityp nicht erlaubt: {ct or 'unbekannt'}")
|
||||
|
||||
|
||||
|
|
@ -3083,16 +3062,28 @@ def delete_exercise_variant(
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
def _content_disposition_inline(filename: Optional[str]) -> str:
|
||||
"""Inline-Darstellung im Browser (<video>/<img>), keine attachment-Download-Leiste."""
|
||||
def _safe_download_name(filename: Optional[str]) -> str:
|
||||
"""Dateiname ohne Steuerzeichen und ohne Anführungszeichen für Content-Disposition."""
|
||||
if not filename:
|
||||
return "inline"
|
||||
fn = str(filename).strip()
|
||||
return ""
|
||||
cleaned = []
|
||||
for ch in str(filename):
|
||||
if ch < " " or ch == "\x7f" or ch in {'"', "\\", ";"}:
|
||||
continue
|
||||
cleaned.append(ch)
|
||||
return "".join(cleaned).strip()[:180]
|
||||
|
||||
|
||||
def _content_disposition(filename: Optional[str], *, attachment: bool) -> str:
|
||||
"""Bilder und Videos inline. PDF als Anhang. Dateiname ohne Steuerzeichen."""
|
||||
disposition = "attachment" if attachment else "inline"
|
||||
fn = _safe_download_name(filename)
|
||||
if not fn:
|
||||
return disposition
|
||||
quoted = quote(fn, safe="")
|
||||
escaped = fn.replace('"', "\\")
|
||||
if quoted != fn:
|
||||
return f"inline; filename*=utf-8''{quoted}"
|
||||
return f'inline; filename="{escaped}"'
|
||||
return f"{disposition}; filename*=utf-8''{quoted}"
|
||||
return f'{disposition}; filename="{fn}"'
|
||||
|
||||
|
||||
def _parse_bytes_range_single(range_header: str, file_size: int) -> Optional[Tuple[int, int]]:
|
||||
|
|
@ -3152,7 +3143,10 @@ def _binary_media_response(
|
|||
path_str = str(path_arg.resolve())
|
||||
stat_r = os.stat(path_str)
|
||||
file_size = int(stat_r.st_size)
|
||||
cd_val = _content_disposition_inline(download_name)
|
||||
cd_val = _content_disposition(
|
||||
download_name,
|
||||
attachment=(mime_type or "").split(";")[0].strip().lower() == "application/pdf",
|
||||
)
|
||||
base_h = {"accept-ranges": "bytes", "content-disposition": cd_val}
|
||||
|
||||
if request.method.upper() == "HEAD":
|
||||
|
|
@ -3224,7 +3218,7 @@ def download_exercise_media_file(
|
|||
tenant: TenantContext = Depends(get_tenant_context_flexible),
|
||||
):
|
||||
"""
|
||||
Dateiauslieferung mit Governance wie GET Übung — Auth via X-Auth-Token oder ?ssetoken (für <img>/<video>).
|
||||
Dateiauslieferung mit Governance wie GET Übung — Header-Sitzung oder kurzlebiges Medien-Token (?ssetoken).
|
||||
Unterstützt HTTP Range (206) für MP4-Streaming und Content-Disposition: inline (kein attachment).
|
||||
"""
|
||||
with get_db() as conn:
|
||||
|
|
|
|||
|
|
@ -1225,7 +1225,7 @@ def download_media_asset_file(
|
|||
asset_id: int,
|
||||
tenant: TenantContext = Depends(get_tenant_context_flexible),
|
||||
):
|
||||
"""Direktzugriff auf Archiv-Datei (Thumbnail/Vorschau); Auth wie Übungs-Medien (?ssetoken)."""
|
||||
"""Direktzugriff auf Archiv-Datei. Auth per Header oder kurzlebigem Medien-Token (?ssetoken)."""
|
||||
from routers.exercises import _binary_media_response
|
||||
|
||||
with get_db() as conn:
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from psycopg2.extras import Json
|
|||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth, hash_pin
|
||||
from auth import require_auth, hash_pin, revoke_profile_sessions
|
||||
from club_tenancy import (
|
||||
assert_club_member,
|
||||
club_ids_for_profile_with_roles,
|
||||
|
|
@ -30,6 +30,19 @@ router = APIRouter(prefix="/api", tags=["profiles"])
|
|||
|
||||
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
|
||||
_CLUB_BLOCKED_PORTAL_TARGETS = frozenset({"admin", "superadmin"})
|
||||
_PROFILE_SECRET_FIELDS = (
|
||||
"pin_hash",
|
||||
"totp_secret",
|
||||
"totp_pending_secret",
|
||||
"verification_token",
|
||||
)
|
||||
|
||||
|
||||
def _public_profile_row(row) -> dict:
|
||||
data = r2d(row)
|
||||
for key in _PROFILE_SECRET_FIELDS:
|
||||
data.pop(key, None)
|
||||
return data
|
||||
|
||||
|
||||
class ManagementPasswordResetBody(BaseModel):
|
||||
|
|
@ -103,8 +116,7 @@ def get_current_profile(
|
|||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Profil nicht gefunden")
|
||||
data = r2d(row)
|
||||
data.pop("pin_hash", None)
|
||||
data = _public_profile_row(row)
|
||||
clubs = memberships_with_roles(cur, profile_id, active_only=False)
|
||||
data["clubs"] = clubs
|
||||
ac_raw = data.get("active_club_id")
|
||||
|
|
@ -140,9 +152,7 @@ def list_profiles(session=Depends(require_auth)):
|
|||
rows = cur.fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = r2d(r)
|
||||
d.pop("pin_hash", None)
|
||||
out.append(d)
|
||||
out.append(_public_profile_row(r))
|
||||
return out
|
||||
|
||||
|
||||
|
|
@ -203,9 +213,7 @@ def profile_document(pid: str) -> dict:
|
|||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Profil nicht gefunden")
|
||||
d = r2d(row)
|
||||
d.pop("pin_hash", None)
|
||||
return d
|
||||
return _public_profile_row(row)
|
||||
|
||||
|
||||
@router.post("/profiles/{pid}/management-password-reset")
|
||||
|
|
@ -245,9 +253,14 @@ def management_password_reset(
|
|||
_assert_can_management_password_help(cur, tenant, target, via_email=False)
|
||||
new_hash = hash_pin(body.new_password)
|
||||
cur.execute(
|
||||
"UPDATE profiles SET pin_hash = %s, updated_at = NOW() WHERE id = %s",
|
||||
"""
|
||||
UPDATE profiles
|
||||
SET pin_hash = %s, failed_login_count = 0, locked_until = NULL, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(new_hash, target),
|
||||
)
|
||||
revoke_profile_sessions(cur, target)
|
||||
return {"ok": True, "mode": "direct"}
|
||||
|
||||
_assert_can_management_password_help(cur, tenant, target, via_email=True)
|
||||
|
|
@ -270,6 +283,39 @@ def management_password_reset(
|
|||
return {"ok": True, "mode": "email", "email_sent": bool(sent)}
|
||||
|
||||
|
||||
@router.post("/profiles/{pid}/totp-reset")
|
||||
def management_totp_reset(pid: str, tenant: TenantContext = Depends(get_tenant_context)):
|
||||
"""Superadmin schaltet Zwei-Faktor ab, wenn der Nutzer den Authenticator nicht mehr hat."""
|
||||
if not is_superadmin(tenant.global_role or ""):
|
||||
raise HTTPException(status_code=403, detail="Nur Super-Admins")
|
||||
try:
|
||||
target = int(pid)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Ungültige Profil-ID")
|
||||
if target == int(tenant.profile_id):
|
||||
raise HTTPException(status_code=400, detail="Eigenen Zwei-Faktor unter Einstellungen ändern")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT id, totp_enabled FROM profiles WHERE id=%s", (target,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Profil nicht gefunden")
|
||||
if not row.get("totp_enabled"):
|
||||
return {"ok": True, "totp_enabled": False, "changed": False}
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE profiles
|
||||
SET totp_enabled=FALSE, totp_secret=NULL, totp_pending_secret=NULL, updated_at=NOW()
|
||||
WHERE id=%s
|
||||
""",
|
||||
(target,),
|
||||
)
|
||||
cur.execute("DELETE FROM profile_recovery_codes WHERE profile_id=%s", (target,))
|
||||
revoke_profile_sessions(cur, target)
|
||||
return {"ok": True, "totp_enabled": False, "changed": True}
|
||||
|
||||
|
||||
@router.get("/profiles/{pid}")
|
||||
def get_profile(pid: str, session=Depends(require_auth)):
|
||||
"""Profil nach ID — nur eigenes Profil oder Plattform-Admin (wie PUT)."""
|
||||
|
|
|
|||
86
backend/tests/test_auth_hardening.py
Normal file
86
backend/tests/test_auth_hardening.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Login-Sperre, TOTP und Medien-Grant ohne Datenbank."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||
os.environ.setdefault("REQUIRE_ADMIN_TOTP", "0")
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from media_grant import issue_media_grant, profile_id_from_media_grant
|
||||
from totp_auth import generate_totp_secret, totp_code, verify_totp
|
||||
import time
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_totp_accepts_current_code() -> None:
|
||||
secret = generate_totp_secret()
|
||||
code = totp_code(secret, int(time.time()) // 30)
|
||||
assert verify_totp(secret, code)
|
||||
assert not verify_totp(secret, "000000") or code == "000000"
|
||||
|
||||
|
||||
def test_media_grant_rejects_session_shaped_token() -> None:
|
||||
assert profile_id_from_media_grant("not-a-media-grant") is None
|
||||
token, _exp = issue_media_grant(42, ttl_seconds=60)
|
||||
assert profile_id_from_media_grant(token) == 42
|
||||
assert profile_id_from_media_grant(token + "x") is None
|
||||
|
||||
|
||||
def test_change_password_requires_current_password() -> None:
|
||||
from pydantic import ValidationError
|
||||
|
||||
from models import ChangePasswordRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
ChangePasswordRequest.model_validate({"pin": "neuespasswort"})
|
||||
|
||||
|
||||
def test_password_reset_rejects_header_injection() -> None:
|
||||
from pydantic import ValidationError
|
||||
|
||||
from models import PasswordResetRequest, ResendVerificationRequest
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
PasswordResetRequest.model_validate({"email": "trainer@example.com\nBcc: x@y.z"})
|
||||
with pytest.raises(ValidationError):
|
||||
ResendVerificationRequest.model_validate({"email": "nicht-eine-email"})
|
||||
|
||||
|
||||
def test_upload_mime_comes_from_bytes_not_client_header() -> None:
|
||||
from routers.exercises import _content_disposition, resolve_upload_mime_type
|
||||
|
||||
jpeg = b"\xff\xd8\xff" + b"\x00" * 16
|
||||
assert resolve_upload_mime_type(jpeg, "text/html", "evil.html") == "image/jpeg"
|
||||
pdf = b"%PDF-1.4\n" + b"0" * 8
|
||||
assert resolve_upload_mime_type(pdf, "image/png", "bild.png") == "application/pdf"
|
||||
try:
|
||||
resolve_upload_mime_type(b"<html><script>alert(1)</script></html>", "image/jpeg", "x.jpg")
|
||||
raised = False
|
||||
except ValueError:
|
||||
raised = True
|
||||
assert raised
|
||||
disposition = _content_disposition('bericht.pdf\r\nX-Evil: 1', attachment=True)
|
||||
assert disposition.startswith("attachment")
|
||||
assert "\r" not in disposition
|
||||
assert "\n" not in disposition
|
||||
|
||||
|
||||
def test_totp_reset_requires_auth(client: TestClient) -> None:
|
||||
response = client.post("/api/profiles/1/totp-reset")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_login_rejects_oversized_password(client: TestClient) -> None:
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "trainer@example.com", "password": "x" * 200},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
|
@ -10,11 +10,31 @@ from exercise_rich_text import (
|
|||
assert_no_inline_media_references_on_create,
|
||||
collect_inline_exercise_media_ids,
|
||||
normalize_inline_exercise_media_markup,
|
||||
sanitize_exercise_rich_html,
|
||||
validate_inline_exercise_media_ids_for_exercise,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_curly_to_span() -> None:
|
||||
def test_normalize_strips_script_and_handlers() -> None:
|
||||
raw = '<p onclick="alert(1)">Hallo <script>alert(1)</script></p><a href="javascript:alert(1)">Link</a>'
|
||||
out = normalize_inline_exercise_media_markup(raw)
|
||||
assert out is not None
|
||||
lowered = out.lower()
|
||||
assert "script" not in lowered
|
||||
assert "onclick" not in lowered
|
||||
assert "javascript:" not in lowered
|
||||
assert "Hallo" in out
|
||||
assert "Link" in out
|
||||
|
||||
|
||||
def test_normalize_keeps_inline_media_span() -> None:
|
||||
raw = '<span class="shinkan-inline-media evil" data-shinkan-exercise-media="7" data-shinkan-exercise-media-size="full">x</span>'
|
||||
out = sanitize_exercise_rich_html(raw)
|
||||
assert out is not None
|
||||
assert 'data-shinkan-exercise-media="7"' in out
|
||||
assert 'data-shinkan-exercise-media-size="full"' in out
|
||||
assert "shinkan-inline-media" in out
|
||||
assert "evil" not in out
|
||||
s = '<p>Vor {{exerciseMedia: 42 }} nach</p>'
|
||||
out = normalize_inline_exercise_media_markup(s)
|
||||
assert 'data-shinkan-exercise-media="42"' in out
|
||||
|
|
|
|||
|
|
@ -83,6 +83,20 @@ def test_strip_off_topic_steps_from_path():
|
|||
assert [s["exercise_id"] for s in out] == [1, 2, 4]
|
||||
|
||||
|
||||
def test_build_gap_fill_goal_text_includes_catalog_exercise_hints():
|
||||
from planning_exercise_semantics import build_semantic_brief
|
||||
|
||||
brief = build_semantic_brief("Mae Geri")
|
||||
text = build_gap_fill_goal_text(
|
||||
goal_query="Mae Geri lernen",
|
||||
brief=brief,
|
||||
spec={"source": "roadmap_unfilled", "phase": "grundlage", "title_hint": "Stand"},
|
||||
catalog_exercise_hints="Primärfokus: Kihon und Partnerübungen mit Technikbezug.",
|
||||
)
|
||||
assert "Katalog-Hinweise Übungsanlage" in text
|
||||
assert "Kihon" in text
|
||||
|
||||
|
||||
def test_build_gap_fill_goal_text_includes_topic():
|
||||
brief = build_semantic_brief("Mae Geri Perfektion")
|
||||
text = build_gap_fill_goal_text(
|
||||
|
|
|
|||
|
|
@ -336,3 +336,29 @@ def test_filter_rematch_skips_preserved_slots():
|
|||
off_topic_steps=[],
|
||||
)
|
||||
assert filtered == {1}
|
||||
|
||||
|
||||
def test_apply_rematch_catalog_guard_skips_unfilled_only():
|
||||
from planning_path_rematch import apply_rematch_catalog_guard
|
||||
|
||||
indices = {0, 1, 2}
|
||||
reasons = {
|
||||
0: "QS-Tier-1",
|
||||
1: "Keine passende Übung für Roadmap-Stufe",
|
||||
2: "Passt nicht zur Haupttechnik",
|
||||
}
|
||||
filtered = apply_rematch_catalog_guard(
|
||||
indices,
|
||||
reasons,
|
||||
rematch_guard="Keine leeren Slots erzwingen.",
|
||||
)
|
||||
assert filtered == {0, 2}
|
||||
|
||||
|
||||
def test_apply_rematch_catalog_guard_inactive_without_guard():
|
||||
from planning_path_rematch import apply_rematch_catalog_guard
|
||||
|
||||
indices = {1}
|
||||
reasons = {1: "Keine passende Übung für Roadmap-Stufe"}
|
||||
assert apply_rematch_catalog_guard(indices, reasons, rematch_guard=None) == {1}
|
||||
assert apply_rematch_catalog_guard(indices, reasons, rematch_guard="") == {1}
|
||||
|
|
|
|||
63
backend/totp_auth.py
Normal file
63
backend/totp_auth.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""TOTP (RFC 6238) und einmalige Wiederherstellungscodes — nur Standardbibliothek."""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import struct
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def generate_totp_secret() -> str:
|
||||
return base64.b32encode(secrets.token_bytes(20)).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode_secret(secret: str) -> bytes:
|
||||
raw = "".join(ch for ch in (secret or "").upper() if ch.isalnum())
|
||||
pad = "=" * ((8 - len(raw) % 8) % 8)
|
||||
return base64.b32decode(raw + pad, casefold=True)
|
||||
|
||||
|
||||
def totp_code(secret: str, counter: int) -> str:
|
||||
digest = hmac.new(_decode_secret(secret), struct.pack(">Q", counter), hashlib.sha1).digest()
|
||||
offset = digest[-1] & 0x0F
|
||||
binary = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF
|
||||
return f"{binary % 1_000_000:06d}"
|
||||
|
||||
|
||||
def verify_totp(secret: Optional[str], code: str, *, window: int = 1, now: Optional[int] = None) -> bool:
|
||||
if not secret:
|
||||
return False
|
||||
digits = "".join(ch for ch in (code or "") if ch.isdigit())
|
||||
if len(digits) != 6:
|
||||
return False
|
||||
moment = int(time.time() if now is None else now)
|
||||
counter = moment // 30
|
||||
for delta in range(-window, window + 1):
|
||||
if hmac.compare_digest(totp_code(secret, counter + delta), digits):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def otpauth_uri(*, secret: str, account_name: str, issuer: str = "Shinkan Jinkendo") -> str:
|
||||
label = quote(f"{issuer}:{account_name}")
|
||||
return (
|
||||
f"otpauth://totp/{label}?secret={quote(secret)}"
|
||||
f"&issuer={quote(issuer)}&algorithm=SHA1&digits=6&period=30"
|
||||
)
|
||||
|
||||
|
||||
def generate_recovery_codes(count: int = 8) -> list[str]:
|
||||
codes = []
|
||||
for _ in range(count):
|
||||
raw = secrets.token_hex(4)
|
||||
codes.append(f"{raw[:4]}-{raw[4:]}")
|
||||
return codes
|
||||
|
||||
|
||||
def hash_recovery_code(code: str) -> str:
|
||||
normalized = "".join(ch for ch in (code or "").lower() if ch.isalnum())
|
||||
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.237"
|
||||
BUILD_DATE = "2026-05-22"
|
||||
DB_SCHEMA_VERSION = "20260607094"
|
||||
APP_VERSION = "0.8.243"
|
||||
BUILD_DATE = "2026-10-13"
|
||||
DB_SCHEMA_VERSION = "20261012096"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
|
||||
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
|
||||
"auth": "1.3.3", # Registrierung und Bestätigung antworten gleich; Reset-Mail nur mit gültiger E-Mail
|
||||
"profiles": "1.8.2", # Superadmin: POST /profiles/{id}/totp-reset
|
||||
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
|
||||
"capabilities": "1.2.0", # Registry-first: module-Spalte; Admin nur registrierte Rechte
|
||||
"rights_registry": "1.0.0", # register_capability/feature + startup sync
|
||||
|
|
@ -16,7 +16,7 @@ MODULE_VERSIONS = {
|
|||
"club_memberships": "1.0.1", # Depends(get_tenant_context)
|
||||
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||
"club_creation_requests": "1.0.1", # superseded wenn freigegebener Verein gelöscht
|
||||
"admin_users": "1.0.0", # GET /api/admin/users
|
||||
"admin_users": "1.0.1", # GET /api/admin/users inkl. totp_enabled
|
||||
"club_features": "1.6.0",
|
||||
"admin_rights": "1.1.0", # Matrix UX, Enforcement-Audit, clear club grants by capability
|
||||
"capability_enforcement_audit": "1.0.0",
|
||||
|
|
@ -37,7 +37,7 @@ MODULE_VERSIONS = {
|
|||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.37.1", # KI-Endpoints: feature_usage nach ai_calls consume
|
||||
"exercises": "2.37.2", # Upload-MIME nur aus Magic Bytes; PDF als Anhang; Rich-Text-Allowlist beim Speichern
|
||||
"planning_exercise_suggest": "0.23.8", # Progressionsgraph: Katalog-Kontext (Fokus/Stil/TT/ZG) im Match-Profil
|
||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||
"training_programs": "0.1.0",
|
||||
|
|
@ -49,15 +49,67 @@ MODULE_VERSIONS = {
|
|||
"membership": "1.0.0",
|
||||
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
|
||||
"maturity_models": "1.4.0", # matrix_stack_bundle: vollständiger Katalog+Modelle+Bindings Export/Import
|
||||
"content_reports": "1.5.1", # P-13: Fruehzeitige 403 fuer plain Admin in set_legal_hold_from_report (CI-Fix)
|
||||
"content_reports": "1.5.2", # POST /content-reports: 10 Meldungen pro Stunde und IP
|
||||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.243",
|
||||
"date": "2026-10-13",
|
||||
"changes": [
|
||||
"Übungstexte werden beim Speichern und in der Anzeige auf eine feste HTML-Allowlist gekürzt.",
|
||||
"Uploads: der Dateityp kommt aus dem Inhalt. PDF wird als Anhang ausgeliefert.",
|
||||
"Registrierung und erneute Bestätigung verraten nicht, ob die E-Mail existiert. Passwort-Reset verlangt eine gültige Adresse.",
|
||||
"Anonyme und angemeldete Inhaltsmeldungen sind auf 10 pro Stunde und IP begrenzt.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.242",
|
||||
"date": "2026-10-13",
|
||||
"changes": [
|
||||
"Superadmin kann die Zwei-Faktor-Anmeldung eines anderen Kontos aufheben, wenn der Authenticator verloren ist.",
|
||||
"Passwort-Reset ändert nur das Passwort. Zwei-Faktor bleibt aktiv, der Link meldet nicht an.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.241",
|
||||
"date": "2026-10-13",
|
||||
"changes": [
|
||||
"Zwei-Faktor-Einrichtung zeigt einen QR-Code. Die Authenticator-App kann das Konto scannen, das Geheimnis bleibt als Ersatz sichtbar.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.240",
|
||||
"date": "2026-10-13",
|
||||
"changes": [
|
||||
"Zwei-Faktor ist freiwillig. REQUIRE_ADMIN_TOTP erzwingt es nur noch, wenn die Variable ausdrücklich true ist.",
|
||||
"Wiederherstellungscodes bleiben sichtbar, bis sie bestätigt sind. Neue Codes lassen sich in den Einstellungen erzeugen.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.239",
|
||||
"date": "2026-10-12",
|
||||
"changes": [
|
||||
"Login: 8 Fehlversuche sperren das Konto 15 Minuten, gleiche bcrypt-Laufzeit bei unbekannter E-Mail, Limit 10/Minute an der echten Client-IP hinter dem Proxy.",
|
||||
"Passwortwechsel verlangt das aktuelle Passwort und beendet alle Sitzungen. Reset-Tokens sind keine Login-Sitzung.",
|
||||
"Zwei-Faktor (TOTP) für Plattform-Admins Pflicht, für andere Konten in den Einstellungen aktivierbar. Migration 096.",
|
||||
"Medienabruf nutzt ein 10-Minuten-Token (?ssetoken), nicht mehr das Sitzungstoken. API- und Dev-DB-Ports nur an 127.0.0.1. AUTO_ADMIN_FIRST_USER standardmäßig aus.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.238",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Planungs-KI H1.5: rematch_guard im Auto-Rematch (kein Rematch nur wegen leerer Roadmap-Stufe).",
|
||||
"hints_on_exercise in Gap-Fill, Übungs-KI-Kontext und intent_context_json (Roadmap-Pipeline).",
|
||||
"Migration 095: keine Migrations-Seeds in catalog_prompt_slots (Prod-sichere Katalogstruktur).",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.237",
|
||||
"date": "2026-05-22",
|
||||
"changes": [
|
||||
"Migration 094: catalog_prompt_slots vollständig befüllt (Karate, SV, alle Trainingsstile/Zielgruppen).",
|
||||
"Migration 094/095: Katalog-Prompt-Slots ohne Migrations-Seeds — Inhalte Admin oder Laufzeit-Fallback.",
|
||||
"catalog_slot_fallbacks: Namens-Fallback bis Admin-Override — gleiche Qualität wie H1-Registry.",
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ services:
|
|||
volumes:
|
||||
- dev-shinkan-db-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5435:5432"
|
||||
- "127.0.0.1:5435:5432"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dev-shinkan-network
|
||||
|
|
@ -38,6 +38,8 @@ services:
|
|||
APP_URL: "${APP_URL:-https://dev.shinkan.jinkendo.de}"
|
||||
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}"
|
||||
ENVIRONMENT: "${ENVIRONMENT:-development}"
|
||||
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}"
|
||||
REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-false}"
|
||||
# M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen)
|
||||
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
|
||||
MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"
|
||||
|
|
@ -51,7 +53,7 @@ services:
|
|||
volumes:
|
||||
- ${SHINKAN_MEDIA_HOST:-/shinkan-media/dev}:${MEDIA_ROOT:-/app/media}
|
||||
ports:
|
||||
- "8098:8000"
|
||||
- "127.0.0.1:8098:8000"
|
||||
depends_on:
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ services:
|
|||
SMTP_SSL: ${SMTP_SSL:-}
|
||||
SMTP_STARTTLS: ${SMTP_STARTTLS:-}
|
||||
# Erste Self-Registration → Admin; oder ADMIN_BOOTSTRAP_EMAILS=mail@…,weitere@…
|
||||
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-true}"
|
||||
AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-false}"
|
||||
REQUIRE_ADMIN_TOTP: "${REQUIRE_ADMIN_TOTP:-false}"
|
||||
ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}"
|
||||
# Werte wie in .env (APP_URL, ALLOWED_ORIGINS, ENVIRONMENT) — keine zweite „Wahrheit“ in YAML
|
||||
APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}"
|
||||
|
|
@ -61,7 +62,7 @@ services:
|
|||
volumes:
|
||||
- ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media}
|
||||
ports:
|
||||
- "8003:8000"
|
||||
- "127.0.0.1:8003:8000"
|
||||
depends_on:
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**Stand:** 2026-05-22 (F15 Graph-Match & getrennte Pfad-QS, lokal nach **0.8.233**)
|
||||
**App-Version / DB-Schema:** App **`0.8.233`** (Planungs-KI F11–F14, Katalog-Kontext); **F15** siehe §2.8 — DB unverändert (`DB_SCHEMA_VERSION`, Migration **088**).
|
||||
**Stand:** 2026-05-22 (Katalog-Prompt-Slots H2/H1.5, **0.8.238**)
|
||||
**App-Version / DB-Schema:** App **`0.8.238`**; DB Migration **095** (keine Slot-Seeds; Prod-sichere Katalogstruktur).
|
||||
|
||||
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
|
||||
|
||||
|
|
@ -115,7 +115,8 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
| **F13** | **`planning_catalog_context`** (Fokus/Stil/TT/ZG) im Match + Graph-Artefakt | ✅ **0.8.233** |
|
||||
| **F14** | **`ProgressionGraphEditor`** — Slot-UI + Planungskontext-Dropdowns | ✅ **0.8.233** |
|
||||
| **F15** | Unified Slot-Review (Match-Dialog), getrennte Pfad-QS, `findings_stale` | ✅ lokal (nach 0.8.233) |
|
||||
| **H1** | Katalog-Prompt-Snippets (modulare LLM-Anweisungen) | 🔲 Spec **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
|
||||
| **H1–H2.1** | Katalog-Prompt-Slots (DB, Resolver, Admin-UI, granulare Prompts) | ✅ **0.8.234–238** — Spec **`docs/architecture/PLANNING_CATALOG_PROMPT_SNIPPETS.md`** |
|
||||
| **H1.5** | `rematch_guard`, `hints_on_exercise` in Rematch/Gap-Fill/Intent | ✅ **0.8.238** |
|
||||
|
||||
**Architektur (verbindlich):** Drei Schichten — (1) **Katalog-Dimensionen** (DB, jetzt im Match verdrahtet; **H1:** zusätzlich Prompt-Snippets), (2) **Technik-Disambiguierung** (Code, nur bei `topic_type=technique`), (3) **Didaktik** (Roadmap + LLM-QS, nicht im Vokabular). Progressionsgraph = **Roadmap-first**, **keine Gruppenanalyse**. Bestehender Graph = **leichter Nachfolger-Bias** ab Schritt 2. Trainingsplanung = **eigene Pipeline** (Phase G) — Wiederverwendung der Bausteine, siehe Ist-Doku §16.
|
||||
|
||||
|
|
@ -148,7 +149,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
|
|||
5. Phase D′ — Auto KI-Gap-Fill bei persistent leeren Slots
|
||||
6. **Trainingsplanung Phase G** — Gruppenkontext-Pack, Scopes `training_section` / `framework_slot` (Ist-Doku §16)
|
||||
7. Technik-Katalog konfigurierbar (Backlog)
|
||||
8. **H1** — Katalog-Prompt-Snippets (modulare LLM-Anweisungen)
|
||||
8. **H1–H2.1** — Katalog-Prompt-Slots ✅ · **Prompt-Editor-Überarbeitung** → eigene Session
|
||||
|
||||
#### Übungs-KI Formular / Schnellanlage (Stand **0.8.171**)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Planungs-KI — Katalog-Prompt-Slots (Snippets)
|
||||
|
||||
**Stand:** 2026-05-22
|
||||
**Status:** **H2** umgesetzt (0.8.235) · **H2.1** Admin-UI + granulare Prompts (0.8.236)
|
||||
**Status:** **H2.1** umgesetzt (0.8.236) · **H1.5** Rematch-Guard + Gap-Fill-Hints (0.8.238) · **Prompt-Editor-Überarbeitung** bewusst ausgelagert
|
||||
**Bezüge:** `PLANNING_PROGRESSION_GRAPH_KI.md` §4.4 · `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.4 · `planning_catalog_context.py` · `catalog_prompt_slots.py`
|
||||
|
||||
---
|
||||
|
|
@ -173,12 +173,12 @@ Hardcodierte `SNIPPET_REGISTRY` — Proof of Concept für `catalog_guidance_bloc
|
|||
|
||||
- [x] Slot-Editor an Fokusbereich / Trainingsstil / Zielgruppe / Stilrichtung (`CatalogPromptSlotsEditor`, Stammdaten-Katalog)
|
||||
- [x] Prompt-Templates mit granularen Platzhaltern (Migration 093)
|
||||
- [ ] Platzhalter-Hilfe im KI-Prompt-Editor (erweitert)
|
||||
- [ ] **Prompt-Editor komplett überarbeiten** (Platzhalter-Hilfe, UX) — **eigene Session**
|
||||
|
||||
### H1.5
|
||||
### H1.5 ✓ (0.8.238)
|
||||
|
||||
- [ ] `rematch_guard` im Rematch-Loop
|
||||
- [ ] Intent-Prompts + Gap-Fill: `hints_on_exercise`
|
||||
- [x] `rematch_guard` im Rematch-Loop (`apply_rematch_catalog_guard`)
|
||||
- [x] Gap-Fill + Intent: `hints_on_exercise` via `catalog_exercise_hints_block` / `intent_context_json`
|
||||
|
||||
### H3 — Trainingsplanung (Phase G)
|
||||
|
||||
|
|
@ -211,6 +211,7 @@ Hardcodierte `SNIPPET_REGISTRY` — Proof of Concept für `catalog_guidance_bloc
|
|||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 2026-05-22 | **H1.5** (0.8.238): rematch_guard; hints_on_exercise in Gap-Fill/Intent; Migration 095 ohne Seeds |
|
||||
| 2026-05-22 | **H2.1** (0.8.236): Admin-UI `CatalogPromptSlotsEditor`; Migration 093 granulare Prompt-Templates |
|
||||
| 2026-05-22 | **H2** (0.8.235): Slot-Typ-Register + `catalog_prompt_slots` DB, granulare Platzhalter, Admin-API |
|
||||
| 2026-05-22 | Konzept §4–§8: zwei Ebenen Slot-Typ vs. Slot-Wert; Platzhalter `{kind}_{slot_key}` |
|
||||
|
|
|
|||
358
docs/jinkendo-family/DESIGN_PRINCIPLES_ALIGNMENT.md
Normal file
358
docs/jinkendo-family/DESIGN_PRINCIPLES_ALIGNMENT.md
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
# Designprinzipien – Abgleich Mitai ↔ Shinkan
|
||||
|
||||
**Status:** Review / Entscheidungsgrundlage
|
||||
**Stand:** 2026-07-04
|
||||
**Zweck:** Widersprüche, bewusste Abweichungen und Implementierungslücken zwischen den Designprinzipien-Serien identifizieren — Basis für **Familien-Entscheidungen** und langfristige Konvergenz von Mitai und Shinkan.
|
||||
|
||||
**Quellen:**
|
||||
|
||||
| App | Index |
|
||||
|-----|--------|
|
||||
| Mitai (Foundation) | [mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md) — 9 Module |
|
||||
| Shinkan | [design-principles/DESIGN_PRINCIPLES_INDEX.md](./design-principles/DESIGN_PRINCIPLES_INDEX.md) — 15 Module |
|
||||
|
||||
---
|
||||
|
||||
## 1. Kurzfassung
|
||||
|
||||
| Kategorie | Anzahl | Bedeutung |
|
||||
|-----------|--------|-----------|
|
||||
| **Familien-Konsens** | 12 Muster | In beiden Serien gleich oder kompatibel — **verbindlich für neue Apps** |
|
||||
| **Bewusste Produkt-Abweichung** | 8 | Fachlich/Architektur begründet — **nicht angleichen**, aber im Familienmodell verankern |
|
||||
| **Konzeptuelle Spannung** | 6 | Widersprüche oder gegenläufige Defaults — **Familien-Entscheidung nötig** |
|
||||
| **Ist vs. Prinzip (Schuld)** | 14+ | Mindestens eine App verletzt eigene oder Schwester-Prinzipien — **Remediation** |
|
||||
| **Nur Shinkan** | 6 Module | Mandanten-/Domänen-Bausteine ohne Mitai-Pendant |
|
||||
| **Nur Mitai (reifer)** | 3 Muster | Data Layer, Widget-Dashboard, Universal Import — Shinkan vereinfacht oder fehlt |
|
||||
|
||||
**Kernbefund:** Mitai und Shinkan teilen dieselbe **technische Basis** (Auth, Migration, Nav-SSoT, Registry-Denken, Probe→Enforce), divergieren aber strukturell bei **Entitlement-Subjekt** (Profil vs. Verein), **Berechnungsarchitektur** (generischer Data Layer vs. domänenspezifisches Scoring) und **KI-Reife** (Unified Executor vs. schmale Laufzeit).
|
||||
|
||||
---
|
||||
|
||||
## 2. Familien-Konsens (für neue Produkte übernehmen)
|
||||
|
||||
Diese Muster sind in beiden Serien explizit oder implizit tragfähig:
|
||||
|
||||
| # | Muster | Mitai | Shinkan |
|
||||
|---|--------|-------|---------|
|
||||
| F1 | Server-Sessions + `Depends(require_auth)` | Auth #1–3 | Auth #1–2 |
|
||||
| F2 | `profile_id` aus Session, nie aus Client-Header | Auth #3 | Auth #3 |
|
||||
| F3 | Auth getrennt von Authorization/Entitlements | Auth #10 | Capabilities + Access Layer |
|
||||
| F4 | Nummerierte SQL-Migrationen + Tracking beim Container-Start | Migration #1–5 | Migration #1–3 |
|
||||
| F5 | Fail-fast, kein Auto-Rollback | Migration #5 | Migration #2 |
|
||||
| F6 | `appNav` / zentrale Nav-Config als SSoT | Navigation #1 | Navigation #1 |
|
||||
| F7 | Admin als eigener Hub/Realm | Navigation #6–7 | Navigation #2 |
|
||||
| F8 | DB-konfigurierbare KI-Prompts (nicht hardcoded Prod) | Prompt #2 | AI Runtime #2 |
|
||||
| F9 | Template vs. Kontext/Daten trennen | Prompt #5 | AI Runtime #4 |
|
||||
| F10 | Ingest ≠ Interpretation beim Import | Import #2 | Wiki Import #1 |
|
||||
| F11 | Preview/Dry-Run vor Massenimport | Import #3+ | Wiki Import #2 |
|
||||
| F12 | 4-Phasen-Rollout Entitlements (Log → Enforce) | Feature #8 | Capability #2 |
|
||||
|
||||
**Empfehlung:** Als **`JINKENDO_FOUNDATION_CHECKLIST`** in künftigen Apps verpflichtend; Details pro Modul in den Einzeldokumenten.
|
||||
|
||||
---
|
||||
|
||||
## 3. Modul-Abgleich (9 vergleichbare Paare)
|
||||
|
||||
Legende **Bewertung:**
|
||||
|
||||
| Symbol | Bedeutung |
|
||||
|--------|-----------|
|
||||
| ✅ | Prinzipien aligned / kompatibel |
|
||||
| ⚠️ | Teilweise aligned; Lücken in Implementierung oder Doku |
|
||||
| 🔀 | Bewusste Produkt-Divergenz (kein Bug) |
|
||||
| ❌ | Widerspruch oder gegenläufiges Konzept — Entscheidung nötig |
|
||||
| 🏗️ | Ist-Stand verletzt dokumentierte Prinzipien (Architekturschuld) |
|
||||
|
||||
---
|
||||
|
||||
### 3.1 Prompt Engine (Mitai #1) ↔ AI Prompt Runtime (Shinkan #5)
|
||||
|
||||
| Aspekt | Mitai | Shinkan | Bewertung |
|
||||
|--------|-------|---------|-----------|
|
||||
| Single Entry Point | `execute_prompt` / Unified System | `ai_prompt_runtime` + verteilte Orchestratoren | ❌ Konzept |
|
||||
| Prompt-Typen | base / pipeline / workflow | nur slug + Mustache | 🔀 Shinkan bewusst schlanker |
|
||||
| Platzhalter-Registry | zentral, API-Verträge | Kontext-Arten (`AiPromptContextKind`), kein Registry-Katalog | ⚠️ |
|
||||
| Data Layer-Anbindung | Layer 1 → Resolver | Domänen-Builder ad hoc | ⚠️ |
|
||||
| Debug/Preview | ausgereift | Admin-Vorschau, weniger Runtime-Transparenz | ⚠️ |
|
||||
| Feature-Gating an Execute | teils fehlend (Legacy) | Capability geplant, teils Probe | 🏗️ beide |
|
||||
|
||||
**Widersprüche / gegenläufig:**
|
||||
|
||||
- Mitai: **Ein Executor** ist Kernprinzip. Shinkan: **kein** vergleichbarer Executor — Planungs-KI umgeht teils die Laufzeit.
|
||||
- Beide warnen vor **parallelen KI-Pfaden**; beide haben sie noch (Mitai `insights.py`, Shinkan Router-OpenRouter).
|
||||
|
||||
**Familien-Entscheidung (Vorschlag):**
|
||||
|
||||
| Option | Inhalt |
|
||||
|--------|--------|
|
||||
| **Zielbild** | Gemeinsame **`prompt_executor`-Fassade** (Package oder Copy mit Namespace); Shinkan-Kontext-Builder als Plugins |
|
||||
| **Shinkan-Roadmap** | Planungs-Orchestrierung in Laufzeit ziehen; keine Workflow-Graphs vor Planungs-Kontext-Reife |
|
||||
| **Nicht kopieren** | Mitai: doppelte Pipeline-Modelle, PLACEHOLDER_MAP-Duplikat, Roh-SQL im Executor |
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Data Layer (Mitai #2) ↔ Skill Scoring (Shinkan #8)
|
||||
|
||||
| Aspekt | Mitai | Shinkan | Bewertung |
|
||||
|--------|-------|---------|-----------|
|
||||
| Berechnungs-SSoT | `data_layer/` Layer 0→1→2 | nur `skill_scoring.py` | ❌ Abdeckung |
|
||||
| Router delegieren | explizites Prinzip #13 | Skill-Router ja; Planung teils nicht | ⚠️ |
|
||||
| Confidence / data_points | Pflicht-Metadaten | nicht analog | 🔀 Domäne anders |
|
||||
| Chart/KPI-Anbindung | Layer 2b Adapter | KPI-Dashboard ruft Router-Helfer | ⚠️ |
|
||||
| Import-Grenze | keine Scores beim Insert | Wiki: explizit kein Scoring beim Insert | ✅ |
|
||||
|
||||
**Widerspruch:**
|
||||
|
||||
- Mitai postuliert **generische Berechnungsschicht** für die ganze App. Shinkan hat **kein** Data Layer — nur ein **domänenspezifisches** Scoring-Modul. Das ist keine Implementierungslücke allein, sondern **unterschiedliche Architektur-Tiefe**.
|
||||
|
||||
**Familien-Entscheidung (Vorschlag):**
|
||||
|
||||
| Option | Inhalt |
|
||||
|--------|--------|
|
||||
| **Familien-Prinzip** | „Berechnungen in benannter Schicht, nicht in Router/React“ — **Ja** |
|
||||
| **Implementierung** | Mitai: `data_layer/` bleibt Referenz. Shinkan: Skill Scoring **ist** Layer-1-Vorbild; langfristig **`planning_metrics/`** o. ä. statt Router-SQL |
|
||||
| **Nicht verallgemeinern** | Mitai-Formeln (TDEE, WHR) — nur Schichtenmodell übernehmen |
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Feature & Entitlement (Mitai #3) ↔ Capability & Club Features (Shinkan #2)
|
||||
|
||||
| Aspekt | Mitai | Shinkan | Bewertung |
|
||||
|--------|-------|---------|-----------|
|
||||
| Subjekt | **Profil** + Tier | **Verein** (`club_id`) + Plan | ❌ Scope |
|
||||
| Auflösungs-API | `check_feature_access` | `check_capability` + `club_features` + `/me/entitlements` | 🔀 |
|
||||
| Rollout 4 Phasen | ja | ja (Env-Flags) | ✅ |
|
||||
| Registry | DB `features` + Tiers | Code-Registry → DB Sync | ⚠️ |
|
||||
| Capabilities vs. Features | Features only | Capabilities **und** Kontingente getrennt | 🔀 Shinkan feiner |
|
||||
| Widget/Layout-Gating | zentral | Entitlements-API, kein Widget-Layout | ⚠️ |
|
||||
|
||||
**Größter Familien-Konflikt:**
|
||||
|
||||
Mitai-Dokument #3 „Nicht übernehmen“ Punkt 10: *„Profile as Entitlement-Subject — Multi-App-Familie braucht separates Identity/Subscription-Boundary.“*
|
||||
Shinkan **ist** die Antwort mit Verein als Subjekt — aber es gibt **kein gemeinsames Familienmodell**, das Profil-Tier **und** Org-Limits kombiniert.
|
||||
|
||||
**Familien-Entscheidung (Vorschlag):**
|
||||
|
||||
```
|
||||
Entitlement-Subjekt (familie):
|
||||
├── account (profile_id) → Tier, persönliche Limits (Mitai)
|
||||
└── tenant (club_id?) → Org-Plan, Capabilities (Shinkan, optional null)
|
||||
|
||||
API: GET /me/entitlements?tenant_id=
|
||||
Enforcement: eine resolve_entitlement(subject, capability|feature)
|
||||
```
|
||||
|
||||
| App | Remediation |
|
||||
|-----|-------------|
|
||||
| Mitai | Org-Scope reservieren; Tier-Drift (#3 Schuld) bereinigen |
|
||||
| Shinkan | `CLUB_FEATURE_ENFORCE=1` produktiv; Mitai-Legacy `check_feature_access` nicht nutzen (bereits Regel) |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Registry / Plugin (Mitai #4) ↔ Rights Registry (Shinkan #3)
|
||||
|
||||
| Aspekt | Mitai | Shinkan | Bewertung |
|
||||
|--------|-------|---------|-----------|
|
||||
| Registry-first | Platzhalter, Widgets, CSV-Module | Capabilities + Features only | ⚠️ Abdeckung |
|
||||
| Validierung an Grenze | ja | ja (`register_*` wirft) | ✅ |
|
||||
| Runtime + DB | Dual (Katalog + DB-Overrides) | Code → DB Upsert | ✅ |
|
||||
| Dual Registry FE/BE | Widgets + `registerDashboardWidgets` | nicht vorhanden | 🔀 |
|
||||
| Metadaten-Tiefe | nach Risiko (Matrix in Doc) | schlankere Dataclasses | ✅ |
|
||||
|
||||
**Kein Widerspruch** — Shinkan Rights Registry ist **Teilmenge** des Mitai-Meta-Musters.
|
||||
|
||||
**Familien-Entscheidung:** Mitai-Registry-Matrix (Platzhalter / UI-Plugin / Import-Modul / Rechte) als **Familien-Taxonomie**; Shinkan erweitert um Import-/Prompt-Registry wenn Wiki-Import generisch wird.
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Auth & Session (Mitai #5 ↔ Shinkan #4)
|
||||
|
||||
| Aspekt | Mitai | Shinkan | Bewertung |
|
||||
|--------|-------|---------|-----------|
|
||||
| Session-Modell | opaque token | gleich (shared `auth.py`) | ✅ |
|
||||
| Depends-Pattern | ja | ja (+ TenantContext) | ✅ |
|
||||
| IDOR Profile-Header | dokumentierte Schwäche | Shinkan: Session-only betont | ⚠️ prüfen |
|
||||
| RBAC | `role` admin/user | Portal-Rolle **+** Vereinsrollen | 🔀 |
|
||||
| Rate Limiting | ja | (Mitai-spezifisch in Router) | ⚠️ |
|
||||
| Feature-Flags in Session | Legacy-Spalten | Account-Lifecycle separat | 🏗️ Mitai |
|
||||
|
||||
**Gegenläufig:** Shinkan **erweitert** Auth um Mandanten — darf Mandantenlogik **nicht** in `auth.py` legen (Shinkan-Prinzip „Nicht übernehmen“).
|
||||
|
||||
**Familien-Entscheidung:** Gemeinsames Auth-Modul; **TenantContext** als optionales Add-on-Pattern für mandantenfähige Apps.
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Universal Import (Mitai #6) ↔ Wiki Import (Shinkan #11)
|
||||
|
||||
| Aspekt | Mitai | Shinkan | Bewertung |
|
||||
|--------|-------|---------|-----------|
|
||||
| Ingest ≠ Interpretation | ✅ | ✅ | ✅ |
|
||||
| Modul-Registry | zentral | fehlt (wiki-hardcoded) | ⚠️ |
|
||||
| SAVEPOINT pro Zeile | ja | nicht dokumentiert | ⚠️ |
|
||||
| Vorlagen/Mappings | generisch | SMW-Kategorien via Env | 🔀 |
|
||||
| Feature-Limits | an Import gebunden | Admin-only | ⚠️ |
|
||||
|
||||
**Kein Konflikt** — unterschiedliche Reife. Shinkan ist **Spezialfall** des Mitai-Musters.
|
||||
|
||||
**Familien-Entscheidung:** Neue Import-Quellen über **Universal-Import-Gerüst** (Mitai); Wiki als `import_type=mediawiki` registrieren.
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Dashboard Widgets (Mitai #7) ↔ Dashboard KPIs (Shinkan #14)
|
||||
|
||||
| Aspekt | Mitai | Shinkan | Bewertung |
|
||||
|--------|-------|---------|-----------|
|
||||
| UX-Modell | konfigurierbares Widget-Layout | festes KPI-Aggregat | ❌ UX-Konzept |
|
||||
| Chatty Client vermeiden | via Widget-Daten | via `/dashboard/kpis` | ✅ Ziel |
|
||||
| Entitlements | `allowed` pro Widget | TenantContext auf KPIs | ✅ |
|
||||
| Data Layer | Widgets konsumieren Layer 1 | intern Router-Helfer | ⚠️ |
|
||||
|
||||
**Gegenläufig:** Mitai: **Nutzer konfiguriert Dashboard**. Shinkan: **Produkt definiert feste Kacheln** — bewusste MVP-Vereinfachung.
|
||||
|
||||
**Familien-Entscheidung:**
|
||||
|
||||
| App-Typ | Dashboard-Pattern |
|
||||
|---------|-------------------|
|
||||
| Personal Tracking (Mitai) | Widget-Katalog + Layout-JSON |
|
||||
| Trainer/Verein (Shinkan) | Aggregierte KPI-Endpoints ausreichend; Widget-System optional Phase 2 |
|
||||
| Neue App | Aggregat-Endpoint **mindestens**; Widget-System wenn Personalisierung nötig |
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Navigation / IA (Mitai #8 ↔ Shinkan #12)
|
||||
|
||||
| Aspekt | Mitai | Shinkan | Bewertung |
|
||||
|--------|-------|---------|-----------|
|
||||
| appNav SSoT | ja | ja | ✅ |
|
||||
| Admin-Hub | Shell + Hub-Gruppen | horizontale `AdminPageNav` | ⚠️ |
|
||||
| Breakpoint 1024px | explizit | „prüfen“ | ⚠️ |
|
||||
| Onboarding-Nav | — | reduziert ohne Verein | 🔀 Shinkan |
|
||||
| adminNav.js SSoT | empfohlen | hardcoded Array in JSX | 🏗️ Shinkan |
|
||||
| Safe Area PWA | ja | Design-System, weniger explizit | ⚠️ |
|
||||
|
||||
**Familien-Entscheidung:** `appNav.js` + **`adminNav.js`** als Pflicht; Shinkan `AdminPageNav` refactoren.
|
||||
|
||||
---
|
||||
|
||||
### 3.9 Migration & Deploy (Mitai #9 ↔ Shinkan #13)
|
||||
|
||||
| Aspekt | Mitai | Shinkan | Bewertung |
|
||||
|--------|-------|---------|-----------|
|
||||
| XXX_*.sql + schema_migrations | ✅ | ✅ | ✅ |
|
||||
| Startup vor App | ✅ | ✅ | ✅ |
|
||||
| develop/main | ✅ | ✅ | ✅ |
|
||||
| Feste Ports | ✅ | ✅ | ✅ |
|
||||
| Immutabler Docker-Build | dokumentiert | nicht im Shinkan-Doc | ⚠️ Doku |
|
||||
| Health-Check / PG wait | ausführlich | kürzer | ⚠️ Doku |
|
||||
|
||||
**Aligned** — Shinkan-Dokument ist **Untermenge**; Implementierung vermutlich gleich (shared Infra).
|
||||
|
||||
---
|
||||
|
||||
## 4. Nur Shinkan (6 Module) — Einordnung für die Familie
|
||||
|
||||
| Modul | Familien-Relevanz | Mitai-Bezug |
|
||||
|-------|-------------------|------------|
|
||||
| **Access Layer & Tenant** | **Pflicht** für mandantenfähige Apps | Mitai #3 fordert Org-Boundary — hier ausformuliert |
|
||||
| **Media Assets & Archiv** | Optional (Content-Apps) | — |
|
||||
| **Exercise Catalog** | Shinkan-Domäne | — |
|
||||
| **Training Planning** | Shinkan-Domäne | — |
|
||||
| **Content Reports (P-13)** | Empfohlen für UGC/Plattform | — |
|
||||
| **Maturity Models** | Optional (Kompetenz-Apps) | — |
|
||||
|
||||
**Kein Widerspruch zu Mitai** — ergänzen das Familienmodell um **Mandant + Content-Governance**.
|
||||
|
||||
---
|
||||
|
||||
## 5. Querschnitt: Ist-Stand vs. dokumentierte Prinzipien
|
||||
|
||||
Gemeinsame **Architekturschuld** (beide Apps verletzen teils eigene „Nicht übernehmen“-Listen):
|
||||
|
||||
| Thema | Mitai | Shinkan |
|
||||
|-------|-------|---------|
|
||||
| Parallele KI-Pfade | `insights.py` Legacy | OpenRouter direkt in Routern |
|
||||
| Entitlement Enforcement | teils UI-only / Legacy-Spalten | Env Probe-only |
|
||||
| Registry-Sync / Duplikat | PLACEHOLDER_MAP + Registry | Capabilities-Sync, kein Prompt-Registry |
|
||||
| Frontend ohne Backend-Gate | teils | Capabilities Probe |
|
||||
| Dokumentations-Drift | Tier vs. Enforcement-Docs | Endpoint-Audit unvollständig |
|
||||
| Monolithische Pages/Client | God Pages, api.js | God Pages, api.js (Roadmap Phase 4) |
|
||||
| Fehlende JSON-Schema-KI | TODO | TODO (explizit vermeiden) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Entscheidungs-Matrix (Priorisiert)
|
||||
|
||||
| Prio | Entscheidung | Betroffene Apps | Empfohlene Familien-Regel |
|
||||
|------|--------------|-----------------|---------------------------|
|
||||
| **P0** | Entitlement-Subjekt: Profil **und** optional Tenant | Mitai, Shinkan, neu | Ein API-Shape `/me/entitlements`; zwei Subjekt-Ebenen |
|
||||
| **P0** | Kein Client-`profile_id` für AuthZ | alle | Session-only; Tenant via Header + Membership |
|
||||
| **P1** | KI: ein Executor pro App | Mitai (fertig), Shinkan (Ziel) | `execute_prompt(slug, context_dto)` |
|
||||
| **P1** | Berechnungs-SSoT-Schicht | Shinkan erweitern | Mindestens ein `*/metrics.py` pro Domäne mit KPIs |
|
||||
| **P1** | Enforcement produktiv | beide | Phase 4 Enforce in Prod für kritische Features |
|
||||
| **P2** | Registry-Taxonomie vereinheitlichen | beide | Rechte / Platzhalter / Import / UI-Plugin |
|
||||
| **P2** | Admin-Nav SSoT | Shinkan | `adminNav.js` wie Mitai |
|
||||
| **P2** | Import: Universal + Spezialmodule | Shinkan | Wiki als registriertes Modul |
|
||||
| **P3** | Dashboard: Aggregat vs. Widgets | produktabhängig | Entscheidungsbaum §3.7 |
|
||||
| **P3** | Shared `auth.py` / `db_init` | beide | Monorepo-Package oder Sync-Disziplin |
|
||||
|
||||
---
|
||||
|
||||
## 7. Konvergenz-Roadmap (langfristig)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph foundation [Familien-Foundation]
|
||||
M9[Migration]
|
||||
M5[Auth]
|
||||
F3[Entitlements 2-Ebenen]
|
||||
R4[Registry Meta]
|
||||
end
|
||||
|
||||
subgraph mitai [Mitai]
|
||||
DL[Data Layer]
|
||||
PE[Prompt Engine]
|
||||
DW[Dashboard Widgets]
|
||||
end
|
||||
|
||||
subgraph shinkan [Shinkan]
|
||||
AL[Access Layer]
|
||||
AR[AI Runtime → Executor]
|
||||
SS[Skill Scoring → Layer 1]
|
||||
end
|
||||
|
||||
M9 --> mitai
|
||||
M9 --> shinkan
|
||||
M5 --> mitai
|
||||
M5 --> shinkan
|
||||
F3 --> mitai
|
||||
F3 --> shinkan
|
||||
R4 --> mitai
|
||||
R4 --> shinkan
|
||||
AL -.->|Mandanten-Apps| foundation
|
||||
PE -.->|Konvergenz| AR
|
||||
DL -.->|Schichtenmodell| SS
|
||||
```
|
||||
|
||||
| Phase | Mitai | Shinkan |
|
||||
|-------|-------|---------|
|
||||
| **Kurz** | Legacy KI-Pfade entfernen; Enforcement-Doku vereinheitlichen | Access-Layer-Audit abschließen; `CAPABILITY_ENFORCE` |
|
||||
| **Mittel** | Org-Scope in Entitlements vorbereiten | `ai_prompt_runtime` → Unified Executor; Router-Helfer statt KPI-Duplikat |
|
||||
| **Lang** | SSO/Identity (Vision) | Data-Layer-ähnliche Module für Planung; optional Widget-Dashboard |
|
||||
|
||||
---
|
||||
|
||||
## 8. Nächste Schritte
|
||||
|
||||
1. **Review-Workshop:** Tabelle §6 P0–P1 durchgehen und Familien-Regeln verbindlich markieren.
|
||||
2. ~~**`FAMILY_ENTITLEMENT_MODEL.md`** anlegen (P0)~~ → [FAMILY_ENTITLEMENT_MODEL.md](./FAMILY_ENTITLEMENT_MODEL.md) (Entwurf 2026-07-04)
|
||||
3. **Shinkan-Index** und **Mitai-Foundation-README** auf dieses Dokument verlinken.
|
||||
4. **`FAMILY_ENTITLEMENT_MODEL.md`** — Entwurf angelegt (§6 Produkt-Abweichungen, §8 Regeln neue Apps).
|
||||
5. Pro **P1-Punkt** Issue/Remediation-Eintrag in jeweiliger `SCHULDEN_UND_REMEDIATION` / Mitai-Äquivalent.
|
||||
|
||||
---
|
||||
|
||||
## 9. Changelog
|
||||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 2026-07-04 | Erstfassung Abgleich Mitai Foundation (9) ↔ Shinkan (15) |
|
||||
316
docs/jinkendo-family/FAMILY_ENTITLEMENT_MODEL.md
Normal file
316
docs/jinkendo-family/FAMILY_ENTITLEMENT_MODEL.md
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
# Familien-Modell: Entitlements & Limits
|
||||
|
||||
**Status:** Entwurf / verbindliche Zielrichtung (mit bewussten Produkt-Ausnahmen)
|
||||
**Stand:** 2026-07-04
|
||||
**Bezüge:**
|
||||
|
||||
- [DESIGN_PRINCIPLES_ALIGNMENT.md](./DESIGN_PRINCIPLES_ALIGNMENT.md) — Abgleich Mitai ↔ Shinkan
|
||||
- Mitai: [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md)
|
||||
- Shinkan: [CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md](./design-principles/CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Zweck
|
||||
|
||||
Dieses Dokument definiert das **gemeinsame Entitlement-Modell** der Jinkendo-Produktfamilie:
|
||||
|
||||
- **Was** für neue Apps und Refactors **Standard** ist
|
||||
- **Welche Produkt-Profile** welche Scopes nutzen (Account, Tenant, beides, keins)
|
||||
- **Wie** bewusste Abweichungen dokumentiert werden — Abweichung ist erlaubt, **Undokumentiertheit** nicht
|
||||
|
||||
**Nicht enthalten:** Stripe/SSO (`auth.jinkendo.de`), vollständige Billing-Implementierung, app-spezifische Feature-IDs.
|
||||
|
||||
---
|
||||
|
||||
## 2. Leitgedanke: Vier getrennte Fragen
|
||||
|
||||
Jede geschützte Aktion durchläuft konzeptionell **vier unabhängige Prüfungen**. Nicht jede App implementiert alle vier — siehe §6.
|
||||
|
||||
| # | Frage | Familien-Begriff | Typische Quelle |
|
||||
|---|--------|------------------|-----------------|
|
||||
| **A** | Wer ist eingeloggt? | **Auth** (Identität) | Session → `profile_id` |
|
||||
| **B** | Darf diese Rolle die **Funktion** ausführen? | **Capability** (Permission) | Rollen-Matrix, `min_account_state` |
|
||||
| **C** | Ist das **Kontingent** erschöpft? | **Feature / Limit** (Quota) | Plan, Tier, Usage-Zähler |
|
||||
| **D** | Darf ich **dieses Objekt** lesen/ändern? | **Governance** (Object ACL) | `visibility`, `club_id`, Owner |
|
||||
|
||||
```
|
||||
Request → Auth (A) → [Capability (B)] → [Feature-Limit (C)] → [Governance (D)] → Handler
|
||||
```
|
||||
|
||||
**Familien-Regel:** B, C und D **nicht** in React-Widgets oder Router-Inline-Logik vermischen — jeweils eine Auflösungsfunktion pro Ebene.
|
||||
|
||||
**Shinkan-Ergänzung:** Governance ist dort ausgebaut (`TenantContext`, Access Layer); Mitai fokussiert A+B+C auf Profil-Ebene.
|
||||
|
||||
---
|
||||
|
||||
## 3. Familien-Standard: Zwei Subjekt-Ebenen
|
||||
|
||||
Limits und Pläne können an **zwei Subjekte** hängen. Beide sind im Familienmodell **first-class** — Apps wählen, welche sie nutzen (§6).
|
||||
|
||||
| Subjekt | ID | Typische Frage | Beispiel |
|
||||
|---------|-----|----------------|----------|
|
||||
| **Account** | `profile_id` | Was darf **ich** als Nutzer (Tarif)? | Mitai Free vs. Premium |
|
||||
| **Tenant** | `tenant_id` (z. B. `club_id`) | Was darf **meine Organisation**? | Shinkan Vereinsplan, KI-Kontingent |
|
||||
|
||||
### 3.1 Auflösungs-Reihenfolge (wenn beide Ebenen aktiv)
|
||||
|
||||
Für eine Aktion mit Capability `X` und Feature `Y`:
|
||||
|
||||
1. **Account-Lifecycle** — z. B. E-Mail verifiziert, Onboarding abgeschlossen
|
||||
2. **Capability(B)** — Rolle darf Funktion (Account- und/oder Tenant-Rollen)
|
||||
3. **Feature(C)** — Kontingent am **primären Billing-Subjekt** der App (siehe Produkt-Profil)
|
||||
4. **Governance(D)** — Objekt sichtbar/bearbeitbar
|
||||
|
||||
**AND-Verknüpfung:** Alle aktiven Ebenen müssen passieren. Ausnahmen nur in §6.3 dokumentiert.
|
||||
|
||||
### 3.2 Primäres Billing-Subjekt pro App
|
||||
|
||||
| Profil | Primäres Subjekt für Limits | Capability-Subjekt |
|
||||
|--------|----------------------------|-------------------|
|
||||
| Personal App (Mitai) | **Account** | Account |
|
||||
| Mandanten-App (Shinkan) | **Tenant** | Account + Tenant-Rolle |
|
||||
| Hybrid (Zukunft) | konfigurierbar | beide |
|
||||
|
||||
---
|
||||
|
||||
## 4. Familien-Standard: Capabilities vs. Features
|
||||
|
||||
| Konzept | Familien-Definition | Subjekt | Beispiel |
|
||||
|---------|---------------------|---------|----------|
|
||||
| **Capability** | Binäre oder rollenbasierte **Erlaubnis** („darf ich?“) | meist Account + Tenant-Kontext | `exercises.ai.suggest` |
|
||||
| **Feature** | **Kontingent** oder Boolean-Limit („wie oft/noch?“) | Account **oder** Tenant | `ai_calls` / Monat |
|
||||
| **Verknüpfung** | Capability kann `linked_feature_id` haben | — | KI-Capability → KI-Kontingent |
|
||||
|
||||
**Familien-Regel:**
|
||||
|
||||
- Capabilities **registry-first** registrieren (Code → DB-Sync, Shinkan-Muster).
|
||||
- Feature-IDs **nicht** in UI hardcoden — nur aus Entitlements-Response.
|
||||
- `NULL` Limit = unbegrenzt; `0` = deaktiviert (Mitai-Semantik, familienweit).
|
||||
|
||||
---
|
||||
|
||||
## 5. Familien-Standard: API & Enforcement
|
||||
|
||||
### 5.1 Ziel-API (neue Apps)
|
||||
|
||||
Ein **einheitlicher Snapshot** für das Frontend:
|
||||
|
||||
```
|
||||
GET /api/me/entitlements
|
||||
?tenant_id=<optional>
|
||||
|
||||
Response (skizziert):
|
||||
{
|
||||
"account": {
|
||||
"profile_id": 1,
|
||||
"account_state": "active_member",
|
||||
"tier_id": "premium", // optional, Account-Apps
|
||||
"features": { "ai_calls": { "allowed", "used", "limit", "remaining", "reset_at" } },
|
||||
"capabilities": { "analysis.run": { "allowed": true, "reason": null } }
|
||||
},
|
||||
"tenant": { // null wenn App keinen Tenant kennt
|
||||
"tenant_id": 42,
|
||||
"tenant_type": "club",
|
||||
"plan_id": "pro",
|
||||
"features": { ... },
|
||||
"capabilities": { ... },
|
||||
"roles": ["trainer"]
|
||||
},
|
||||
"enforcement": {
|
||||
"capabilities": "enforce|probe",
|
||||
"features": "enforce|probe"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Familien-Regel:** UI liest **nur** diesen Snapshot (oder domänenspezifische Teilmenge) — keine parallelen `/subscription/me` + `/features/usage` + Ad-hoc-Checks in neuen Apps.
|
||||
|
||||
### 5.2 Ist-API (bestehende Apps — Abweichung dokumentiert)
|
||||
|
||||
| App | Endpoint heute | Familien-Ziel |
|
||||
|-----|----------------|---------------|
|
||||
| **Mitai** | `/subscription/me`, `/features/usage`, `check_feature_access` | Snapshot schrittweise; Account-Block reicht |
|
||||
| **Shinkan** | `GET /api/me/entitlements?club_id=` | Tenant-Block + Capabilities; Account-Tier fehlt bewusst |
|
||||
|
||||
Migration: **kein Big-Bang** — alte Endpoints als Facade auf Snapshot mappen.
|
||||
|
||||
### 5.3 Vier-Phasen-Rollout (familienweit verbindlich)
|
||||
|
||||
| Phase | Verhalten | Env-Beispiel |
|
||||
|-------|-----------|--------------|
|
||||
| **1** | Cleanup Legacy-Flags | — |
|
||||
| **2** | **Probe** — JSON-Log, HTTP 200 | `*_ENFORCE=0` |
|
||||
| **3** | Frontend-Gates aus Entitlements | — |
|
||||
| **4** | **Enforce** — HTTP 403 | `*_ENFORCE=1` |
|
||||
|
||||
**Familien-Regel:** Phase 4 für **neue** kritische Features von Anfang an planbar; Bestands-Apps dürfen in Phase 2–3 bleiben bis kalibriert.
|
||||
|
||||
### 5.4 Enforcement-Priorität
|
||||
|
||||
1. **API** — autoritativ (`403`)
|
||||
2. **Frontend** — UX (Badges, disabled Buttons)
|
||||
3. **Niemals** — nur UI ohne API-Gate
|
||||
|
||||
---
|
||||
|
||||
## 6. Produkt-Profile & bewusste Abweichungen
|
||||
|
||||
Abweichungen vom Familien-Standard sind **zulässig**, wenn sie in der Tabelle **§6.2** stehen und begründet sind.
|
||||
|
||||
### 6.1 Profil-Matrix (Soll)
|
||||
|
||||
| Profil | Apps | Account-Limits | Tenant-Limits | Capabilities | Governance (Objekt) |
|
||||
|--------|------|----------------|---------------|--------------|---------------------|
|
||||
| **P1 Personal** | Mitai | ✅ primär | ❌ | ✅ Account | minimal / privat |
|
||||
| **P2 Mandant** | Shinkan | ⚠️ Lifecycle only | ✅ primär | ✅ Account + Tenant-Rollen | ✅ Access Layer |
|
||||
| **P3 Minimal** | Miken, Ikigai (geplant) | optional | ❌ | optional | minimal |
|
||||
| **P4 Hybrid** | (Reserve) | ✅ | ✅ | ✅ beide | ✅ |
|
||||
|
||||
### 6.2 Registrierter Abweichungs-Katalog
|
||||
|
||||
| ID | App | Abweichung vom Familien-Standard | Begründung | Review |
|
||||
|----|-----|----------------------------------|------------|--------|
|
||||
| **DEV-01** | Mitai | Kein `tenant`-Block in Entitlements | Persönliche Tracking-App; kein Verein | Beibehalten (P1) |
|
||||
| **DEV-02** | Mitai | Kein Capability-Katalog (nur Features+Tier) | RBAC = admin/user ausreichend | Optional später `account.*` Capabilities |
|
||||
| **DEV-03** | Mitai | Mehrere Nutzer-APIs statt einem Snapshot | Historisch gewachsen | Facade → Snapshot (mittelfristig) |
|
||||
| **DEV-04** | Mitai | Legacy Session-Spalten (`ai_enabled`, …) parallel Features | Migrationsschuld | Bereinigen, nicht in neue Apps |
|
||||
| **DEV-05** | Shinkan | Kein Account-Tier / `profiles.tier` | Verein zahlt, nicht Trainer | Beibehalten (P2) |
|
||||
| **DEV-06** | Shinkan | Capabilities **und** Governance (zwei Achsen) | Trainer vs. Objekt-Rechte | Familien-Vorbild für P2/P4 |
|
||||
| **DEV-07** | Shinkan | `check_feature_access` (Mitai-Legacy) explizit verboten | Falsches Subjekt | Beibehalten |
|
||||
| **DEV-08** | Shinkan | Enforcement oft Probe-only | Rollout-Sicherheit | → Phase 4 bis Datum X |
|
||||
| **DEV-09** | Shinkan | Inventar-Features live gezählt | Drift-Vermeidung | Abweichung OK; in P2 dokumentieren |
|
||||
| **DEV-10** | Beide | Kein atomares Check+Increment | Race bei Parallel-Requests | Familien-Backlog; Workaround dokumentieren |
|
||||
| **DEV-11** | Neue Apps | dürfen **nur** Account **oder** nur Tenant wählen | MVP | Eintrag hier anlegen vor Launch |
|
||||
|
||||
**Neue Abweichung:** Zeile in §6.2 + ggf. ein Satz im App-`CLAUDE.md`.
|
||||
|
||||
### 6.3 Dokumentierte Ausnahmen (Bypass)
|
||||
|
||||
| Ausnahme | Apps | Regel |
|
||||
|----------|------|-------|
|
||||
| Plattform-Admin Audit | Shinkan | Quota-Bypass über Grants, nicht pauschal superadmin |
|
||||
| Admin ohne Auto-Bypass | Mitai | Admins unterliegen Limits (Produktentscheid) |
|
||||
| Öffentliche Routen ohne Auth | alle | Kein Entitlement-Check |
|
||||
|
||||
---
|
||||
|
||||
## 7. Mapping: Familien-Begriff ↔ Implementierung
|
||||
|
||||
### 7.1 Mitai (Profil P1)
|
||||
|
||||
| Familien | Mitai-Implementierung |
|
||||
|----------|----------------------|
|
||||
| Account-Features | `features`, `tier_limits`, `user_feature_usage` |
|
||||
| Account-Tier | `get_effective_tier()`, `access_grants` |
|
||||
| Check | `check_feature_access(profile_id, feature_id)` |
|
||||
| Increment | `increment_feature_usage(profile_id, …)` |
|
||||
| UI | `UsageBadge`, Widget `allowed` |
|
||||
|
||||
### 7.2 Shinkan (Profil P2)
|
||||
|
||||
| Familien | Shinkan-Implementierung |
|
||||
|----------|-------------------------|
|
||||
| Tenant-Features | `club_plan_limits`, `club_feature_usage`, `club_features.py` |
|
||||
| Tenant-Plan | `get_effective_club_plan(club_id)` |
|
||||
| Capabilities | `check_capability`, `capabilities` + `rights_registry` |
|
||||
| Snapshot | `build_me_entitlements()` → `GET /me/entitlements` |
|
||||
| Governance | `TenantContext`, `club_tenancy` — **kein** Ersatz für Capabilities |
|
||||
|
||||
### 7.3 Gemeinsame Muster (copy-ready)
|
||||
|
||||
| Muster | Mitai | Shinkan |
|
||||
|--------|-------|---------|
|
||||
| Feature-Registry in DB | ✅ | ✅ (`app='shinkan'`) |
|
||||
| Plan × Feature Matrix | `tier_limits` | `club_plan_limits` |
|
||||
| Admin-Override | `user_feature_restrictions` | `club_feature_overrides` |
|
||||
| Promo/Trial | `access_grants` | `club_access_grants` |
|
||||
| 4-Phasen-Rollout | ✅ | ✅ |
|
||||
| Registry-first neue IDs | ⚠️ teils hardcoded | ✅ `rights_registrations/` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Entscheidungsregeln für neue Produkte
|
||||
|
||||
### 8.1 Pflicht (alle Apps mit Auth)
|
||||
|
||||
- [ ] Session → `profile_id`; kein Client-Header als Autorität
|
||||
- [ ] Entitlements-Auflösung **eine Funktion pro Ebene** (Capability, Feature)
|
||||
- [ ] API-Enforcement vor UI-Gate
|
||||
- [ ] 4-Phasen-Rollout dokumentiert
|
||||
- [ ] Produkt-Profil (P1–P4) gewählt + Abweichungen in §6.2
|
||||
|
||||
### 8.2 Wenn Personal App (P1)
|
||||
|
||||
- [ ] Primäres Subjekt = Account
|
||||
- [ ] `tenant`-Block in API = `null` (DEV-01-Analog)
|
||||
- [ ] Feature-Registry + Tier-Matrix
|
||||
|
||||
### 8.3 Wenn Mandanten-App (P2)
|
||||
|
||||
- [ ] Primäres Subjekt = Tenant
|
||||
- [ ] `TenantContext` + Governance getrennt von Capabilities
|
||||
- [ ] Capabilities registry-first
|
||||
- [ ] Account nur Lifecycle (verified, member) — kein Tier nötig (DEV-05-Analog erlaubt)
|
||||
|
||||
### 8.4 Wenn Minimal App (P3)
|
||||
|
||||
- [ ] Explizit: „keine Limits“ oder nur Boolean-Features — in §6.2 eintragen
|
||||
- [ ] Kein halbes Mitai-v9c kopieren
|
||||
|
||||
---
|
||||
|
||||
## 9. Konvergenz-Roadmap (optional, nicht blockierend)
|
||||
|
||||
| Schritt | Mitai | Shinkan | Familie |
|
||||
|---------|-------|---------|---------|
|
||||
| **Kurz** | Legacy Session-Flags entfernen | `CAPABILITY_ENFORCE` / `CLUB_FEATURE_ENFORCE` Prod | DEV-04, DEV-08 schließen |
|
||||
| **Mittel** | `/me/entitlements` Account-Block | Snapshot um `tenant`-Typ metadata erweitern | Facade alte APIs |
|
||||
| **Lang** | Optional `tenant_id` reservieren (null) | Optional Account-Tier für Cross-Sell | Shared package `jinkendo_entitlements` |
|
||||
| **Vision** | SSO + zentraler Billing | Vereins-Abo Stripe | `CENTRAL_SUBSCRIPTION_SYSTEM` |
|
||||
|
||||
**Wichtig:** Konvergenz ist **empfohlen**, nicht Pflicht — solange §6.2 aktuell bleibt.
|
||||
|
||||
---
|
||||
|
||||
## 10. Anti-Patterns (familienweit verboten)
|
||||
|
||||
1. Tier- oder Plan-Namen in React-Komponenten hardcoden
|
||||
2. Limit-Logik nur im Frontend
|
||||
3. Shinkan-Vereinslimits über Mitai `check_feature_access(profile_id)`
|
||||
4. Capability-Check durch Governance ersetzen (oder umgekehrt)
|
||||
5. Neue Feature-IDs nur in SQL-Migration ohne Registry
|
||||
6. Enforcement Phase 4 „vergessen“ bei paid Features ohne dokumentierte Probe-Phase
|
||||
7. Undokumentierte Produkt-Abweichung (nicht in §6.2)
|
||||
|
||||
---
|
||||
|
||||
## 11. Offene Familien-Entscheidungen (Backlog)
|
||||
|
||||
| ID | Frage | Optionen | Default wenn unentschieden |
|
||||
|----|-------|----------|----------------------------|
|
||||
| **FD-01** | Atomares check+increment | DB-Lock / Transaction / Queue | Status quo + Retry-Hinweis in Doku |
|
||||
| **FD-02** | Shared Python-Modul | Monorepo-Paket vs. Copy+Sync | Copy+Sync mit gleicher API-Shape |
|
||||
| **FD-03** | Capability-Namespace global | `jinkendo.*` vs. app-prefix | `{app}.{domain}.{action}` |
|
||||
| **FD-04** | Mitai bekommt Capability-Layer? | ja/nein/später | nein (DEV-02) bis Bedarf |
|
||||
| **FD-05** | Ein `features.app` für alle Apps | gemeinsame DB vs. pro Deploy | pro Deploy (heute) |
|
||||
|
||||
---
|
||||
|
||||
## 12. Verwandte Dokumente
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|----------|--------|
|
||||
| [DESIGN_PRINCIPLES_ALIGNMENT.md](./DESIGN_PRINCIPLES_ALIGNMENT.md) | Vollständiger Mitai ↔ Shinkan Abgleich |
|
||||
| [design-principles/CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md](./design-principles/CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md) | Shinkan Ist-Prinzipien |
|
||||
| [ACCESS_LAYER_DESIGN_PRINCIPLES.md](./design-principles/ACCESS_LAYER_DESIGN_PRINCIPLES.md) | Governance (Ebene D) |
|
||||
| Mitai Foundation #3 | Feature & Entitlement Ist Mitai |
|
||||
| Shinkan `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` | Vereins-Abo Detail |
|
||||
| Shinkan `CAPABILITY_CATALOG.v1.md` | Capability-IDs |
|
||||
|
||||
---
|
||||
|
||||
## 13. Changelog
|
||||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 2026-07-04 | Entwurf: Familien-Standard, Produkt-Profile P1–P4, Abweichungs-Katalog DEV-01–11 |
|
||||
48
docs/jinkendo-family/README.md
Normal file
48
docs/jinkendo-family/README.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Jinkendo Produktfamilie – Dokumentation
|
||||
|
||||
**Zweck:** Querschnitts-Dokumentation, die **über einzelne Apps hinaus** gilt (Mitai, Shinkan, künftige Schwester-Produkte).
|
||||
|
||||
**Stand:** 2026-07-04
|
||||
|
||||
---
|
||||
|
||||
## Inhalt
|
||||
|
||||
| Verzeichnis | Zweck |
|
||||
|-------------|--------|
|
||||
| [design-principles/](./design-principles/) | Extrahierte **Designprinzipien** pro Modul — Foundation für neue Apps und Familien-Review |
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
**Einstieg Shinkan:** [design-principles/DESIGN_PRINCIPLES_INDEX.md](./design-principles/DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
||||
**Einstieg Mitai (Foundation):** [mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md)
|
||||
|
||||
**Abgleich & Entscheidungen:** [DESIGN_PRINCIPLES_ALIGNMENT.md](./DESIGN_PRINCIPLES_ALIGNMENT.md) — Widersprüche, Abweichungen, Familien-Entscheidungs-Matrix
|
||||
|
||||
Die Serie dokumentiert:
|
||||
|
||||
- **Mitai** (9 Module) — Referenzimplementierung unter `mitai-jinkendo/.claude/docs/jinkendo-foundation/`
|
||||
- **Shinkan** (15 Module) — in [design-principles/](./design-principles/)
|
||||
|
||||
Geplanter nächster Schritt: **FD-***-Entscheidungen im [FAMILY_ENTITLEMENT_MODEL.md](./FAMILY_ENTITLEMENT_MODEL.md) durcharbeiten; Enforcement Phase 4 wo vorgesehen.
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|----------|--------|
|
||||
| [DESIGN_PRINCIPLES_ALIGNMENT.md](./DESIGN_PRINCIPLES_ALIGNMENT.md) | Mitai ↔ Shinkan Abgleich |
|
||||
| [FAMILY_ENTITLEMENT_MODEL.md](./FAMILY_ENTITLEMENT_MODEL.md) | **Familien-Standard** Entitlements + Produkt-Abweichungen |
|
||||
|
||||
---
|
||||
|
||||
## Bezug zu App-spezifischer Doku
|
||||
|
||||
| Ebene | Ort (Beispiel Shinkan) |
|
||||
|-------|-------------------------|
|
||||
| **Familie** (Prinzipien) | `docs/jinkendo-family/` |
|
||||
| **App-Architektur** | `docs/architecture/` |
|
||||
| **Technische Specs** | `.claude/docs/technical/` |
|
||||
| **Agent-Regeln** | `.claude/rules/`, `CLAUDE.md` |
|
||||
|
||||
Implementierungsdetails und Domänen-Specs bleiben in den jeweiligen App-Repositories; Designprinzipien beschreiben **übertragbare Muster** und **bewusste Lücken**.
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
# Access Layer & Tenant – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Access Layer & Tenant Governance“ — keine Shinkan-Gesamtarchitektur, keine Kampfsport-Domänenlogik
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 1 von 15
|
||||
**Mitai-Vergleich:** kein direktes Gegenstück (Mitai ist profil-zentriert, kein Vereins-Mandant)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| TenantContext | `backend/tenant_context.py` |
|
||||
| Governance-Helfer | `backend/club_tenancy.py` |
|
||||
| Listenfilter SQL | `library_content_visibility_sql()` in `tenant_context.py` |
|
||||
| Endpoint-Audit | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` |
|
||||
| Cursor-Regel | `.cursor/rules/access-layer.mdc` |
|
||||
| Heuristik-Check | `backend/scripts/check_access_layer_hints.py` |
|
||||
| Normative Spec | `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Access Layer & Tenant Governance**
|
||||
|
||||
Zentraler Querschnitt für Mandanten-Kontext (`club_id`), Sichtbarkeit (`private`/`club`/`official`) und einheitliche Les-/Schreibregeln für Bibliotheksartefakte (Übungen, Medien, Rahmenprogramme, Vorlagen, Progressionsgraphen).
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
1. **TenantContext pro HTTP-Request** — Auflösung aus Session + Header `X-Active-Club-Id` + Profilfeld `active_club_id`.
|
||||
2. **Datenisolierung** — `club_id` als Grenze für vereinsgeteilte Inhalte; Cross-Verein ausgeschlossen.
|
||||
3. **Einheitliche Sichtbarkeits-Semantik** — gleiche Enums und Prüflogik über alle Bibliotheksmodule.
|
||||
4. **Governance-Transitionen** — Regeln beim Wechsel `private` → `club` → `official` und beim Löschen.
|
||||
5. **Listenfilter** — SQL-Baustein statt „SELECT *“ in jedem Router.
|
||||
|
||||
### Administrierte Konfigurationen
|
||||
|
||||
| Konfiguration | Speicherort | Inhalt |
|
||||
|---------------|-------------|--------|
|
||||
| Aktiver Verein | `profiles.active_club_id` | Persistierter UI-Kontext |
|
||||
| Request-Override | Header `X-Active-Club-Id` | Client-seitiger Mandantenwechsel |
|
||||
| Sichtbarkeit | Spalte `visibility` je Objekt | `private`, `club`, `official` |
|
||||
| Vereinszuordnung | Spalte `club_id` | Pflicht bei `club`-Inhalten |
|
||||
|
||||
### Bewusst nicht hardcodiert
|
||||
|
||||
- Welche Objekte welchen Verein haben (Daten)
|
||||
- Individuelle Freigabeentscheidungen (Workflow)
|
||||
|
||||
### Hardcodiert (Code)
|
||||
|
||||
- Enum-Werte und Leseregeln in `club_tenancy.py` / `library_content_visibility_sql`
|
||||
- Plattform-Admin-Ausnahmen (`is_platform_admin`, `is_superadmin`)
|
||||
- Rollencodes für Schreib-/Löschregeln (`club_admin`, `trainer`, …)
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Ein Mandant pro Request (`TenantContext`)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `Depends(get_tenant_context)` liefert `profile_id`, `global_role`, `effective_club_id`, Mitgliedschaften — einmal pro Request. |
|
||||
| **Begründung** | Kein verteiltes „Rates“ aus Headers; konsistente Filter in allen Routern. |
|
||||
| **Quelle** | `tenant_context.py`; `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` §2 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nicht alle Endpoints migriert; Audit-Tabelle zeigt Restbestand mit nur `require_auth`. |
|
||||
|
||||
### 2. `club_id` als Datenisolierungsgrenze
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Vereinsgeteilte Inhalte sind nur für aktive Mitglieder des Objekt-`club_id` lesbar — nie Cross-Verein. |
|
||||
| **Begründung** | Mandantenfähigkeit für Vereinsplattform; Compliance bei geteilten Trainingsinhalten. |
|
||||
| **Quelle** | `library_content_visibility_sql()`; Tests `test_access_layer*.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | `division`-Verschärfung noch nicht durchgängig; reserviert im Plan. |
|
||||
|
||||
### 3. Einheitliche Visibility-Semantik
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `private` \| `club` \| `official` mit gleicher Bedeutung für Übungen, Medien, Rahmen, Module, Graphen. |
|
||||
| **Begründung** | Nutzer und Trainer verstehen ein Freigabemodell; UI-Feld „Freigabelevel“ durchgängig. |
|
||||
| **Quelle** | `club_tenancy.py`; `FACHLICHE_NUTZERFUNKTIONEN.md` §4.7 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | `community`-Stufe nur dokumentiert, nicht implementiert. |
|
||||
|
||||
### 4. Zentraler SQL-Filter für Bibliothekslisten
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Listen nutzen `library_content_visibility_sql(alias, profile_id, role, effective_club_id)` — nicht handgeschriebene WHERE-Kopien. |
|
||||
| **Begründung** | Drift-Vermeidung; ein Fix gilt für alle Kataloge. |
|
||||
| **Quelle** | `tenant_context.py`; Router `exercises.py`, `training_framework_programs.py`, … |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Einzelne Legacy-Queries können noch abweichen. |
|
||||
|
||||
### 5. Governance-Transitionen explizit prüfen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Wechsel von `visibility`/`club_id` über `assert_library_content_governance_transition` + `assert_valid_governance_visibility`. |
|
||||
| **Begründung** | „Privat → Verein teilen“ und „official herabstufen“ sind sicherheitsrelevante Aktionen. |
|
||||
| **Quelle** | `club_tenancy.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nicht jedes Modul ruft Transition-Helper bei PATCH auf. |
|
||||
|
||||
### 6. Löschregeln nach Visibility-Stufe
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `assert_library_content_deletable`: privat → Ersteller/Vereinsadmin-Kontext; club → Vereinsadmin; official → Plattform-Admin. |
|
||||
| **Begründung** | Schutz vor versehentlichem Löschen fremder oder offizieller Inhalte. |
|
||||
| **Quelle** | `club_tenancy.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Medien-Lifecycle hat zusätzliche Stufen (Papierkorb) — Schnittmenge beachten. |
|
||||
|
||||
### 7. Aktiver Verein: Header + Profil synchron
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Frontend sendet `X-Active-Club-Id`; Backend validiert gegen Mitgliedschaft; Profil speichert `active_club_id`. |
|
||||
| **Begründung** | Einheitlicher Mandanten-Kontext über API und UI. |
|
||||
| **Quelle** | `frontend/src/api/client.js` (`mergeActiveClubHeader`); `profiles` Router |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Onboarding-Nutzer ohne Verein: eingeschränkter Nav-Modus. |
|
||||
|
||||
### 8. Plattform-Admin als Audit-Pfad, nicht als Bypass
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Plattform-Admins sehen fremde `club`-Inhalte nur mit expliziter Regel (Mitgliedschaft oder Audit-Ausnahme in SQL). |
|
||||
| **Begründung** | Superuser-Zugriff ohne Mandanten-Leak in normalen Trainer-Flows. |
|
||||
| **Quelle** | `library_content_visibility_sql` — `club_ok_plat`-Zweig |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Feinheiten zwischen `admin` und `superadmin` (z. B. `official`, Legal Hold) separat geregelt. |
|
||||
|
||||
### 9. Endpoint-Audit als lebendes Inventar
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Jeder sicherheitsrelevante Endpoint-Eintrag in `ACCESS_LAYER_ENDPOINT_AUDIT.md`; PR-Checkliste verlangt Update. |
|
||||
| **Begründung** | Sichtbarkeit des Migrationsstands; kein stilles Ausweichen auf `require_auth` allein. |
|
||||
| **Quelle** | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`; `check_access_layer_hints.py` |
|
||||
| **Tragfähigkeit** | **hoch** (prozessual) |
|
||||
| **Einschränkung** | CI-Strict-Modus optional, nicht überall aktiv. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Endpoints nur mit `require_auth`** bei tenant-sensitiven Daten — führt zu IDOR und fehlenden Listenfiltern.
|
||||
2. **Visibility-Logik pro Router duplizieren** — historische Drift zwischen Übungen und Planung.
|
||||
3. **`division` vor stabiler Vereins-Isolation** — Reihenfolge im Plan: erst Stufe C, dann D.
|
||||
4. **Community-Freigabe ohne additive Felder** — würde `club`-Isolation brechen.
|
||||
5. **Client-seitige Mandantenfilter ohne Server-Enforcement** — UI-Hiding reicht nicht.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [ACCESS_LAYER_AND_GOVERNANCE_PLAN.md](../../../.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md)
|
||||
- [MULTI_TENANCY_RBAC_ARCHITECTURE.md](../../../.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
# AI Prompt Runtime – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „AI Prompt Runtime“ — Shinkan-KI-Schicht, keine Planungs-Gesamtarchitektur
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 5 von 15
|
||||
**Mitai-Vergleich:** [PROMPT_ENGINE_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/PROMPT_ENGINE_DESIGN_PRINCIPLES.md) (#1)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Laufzeit | `backend/ai_prompt_runtime.py`, `backend/prompt_resolver.py` |
|
||||
| Domänen-Orchestrierung | `backend/exercise_ai.py`, `backend/planning_exercise_*.py` |
|
||||
| OpenRouter | `backend/openrouter_chat.py` |
|
||||
| Admin | `backend/routers/ai_prompts_admin.py` |
|
||||
| Zielbild | `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` |
|
||||
| Job-Kontext | `backend/ai_prompt_job.py`, `backend/ai_prompt_context.py` |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**AI Prompt Runtime**
|
||||
|
||||
Schmale Ausführungsschicht für admin-konfigurierbare Prompts in `ai_prompts`: Laden, Mustache-Rendering, Kontext-Arten, OpenRouter-Aufruf. **Kein** vollständiges Unified Prompt System wie Mitai (keine Workflows/Pipelines in Produktion).
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
1. **Prompt-Laden aus DB** — `load_ai_prompt_row`, `load_and_render_ai_prompt`
|
||||
2. **Platzhalter-Ersetzung** — Mustache `{{key}}` via `prompt_resolver.py`
|
||||
3. **Kontext-Arten** — `AiPromptContextKind` trennt Übungs-KI vs. Planungs-KI
|
||||
4. **Domänen-Builder** — `exercise_ai`, Planungs-Pipelines bauen Variablen-Maps
|
||||
5. **Admin CRUD + Preview** — ohne LLM in Preview-Pfaden wo vorgesehen
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Eine Laufzeit-Fassade für DB-Prompts
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Produktive Aufrufe laden Slugs über `ai_prompt_runtime` — nicht Roh-SQL auf `ai_prompts` in Routern. |
|
||||
| **Begründung** | Einheitliches inactive-Handling, Modell-Feld, Render-Metadaten. |
|
||||
| **Quelle** | `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.1; `ai_prompt_runtime.py` |
|
||||
| **Tragfähigkeit** | **hoch** (Zielrichtung) |
|
||||
| **Einschränkung** | Planungs-KI hat noch verteilte Orchestratoren; kein einzelner `execute_prompt` wie Mitai. |
|
||||
|
||||
### 2. Konfigurierbare Bibliothek in `ai_prompts`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Template-Texte in DB; Admins ändern ohne Deploy (`ai_prompts_admin`). |
|
||||
| **Begründung** | Gleiches Familien-Muster wie Mitai Prompt-Bibliothek. |
|
||||
| **Quelle** | Migration 069+; Admin-UI |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Keine Pipeline/Workflow-Typen; Slugs hardcoded in `context_kind_for_slug`. |
|
||||
|
||||
### 3. Kontext-Namespaces statt globaler Platzhalter-Soup
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `AiPromptContextKind` (z. B. `exercise_form_ai`, `planning_exercise_search`) begrenzt erlaubte Builder. |
|
||||
| **Begründung** | Planungs-Kontext wächst ohne Kollision mit Übungs-Keys. |
|
||||
| **Quelle** | `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.3 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Noch keine zentrale Platzhalter-Registry wie Mitai — Mustache ad hoc pro Builder. |
|
||||
|
||||
### 4. Trennung Template vs. Domänen-Kontext
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | UI/Router liefern Pydantic-DTOs → Builder erzeugen `variables`-Map → `render_mustache_template`. |
|
||||
| **Begründung** | Prompt-Autoren ändern Text, nicht Python in Routern. |
|
||||
| **Quelle** | `prompt_resolver.py`; `ExerciseFormAiPromptContext` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Große Planungs-Kontexte noch nicht vollständig über DTOs. |
|
||||
|
||||
### 5. Transport (OpenRouter) getrennt von Semantik
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `openrouter_chat.py` für HTTP; Validierung/JSON-Parsing in Domänen-Schicht. |
|
||||
| **Begründung** | Modellwechsel ohne Router-Anpassung. |
|
||||
| **Quelle** | `AI_PROMPT_TARGET_ARCHITECTURE.md` §2.2 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Modell teils global Env, teils Spalte `openrouter_model` — Konvergenz offen. |
|
||||
|
||||
### 6. Reset-to-default für System-Prompts
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `default_template` + Admin-Reset — kein vollständiges Versionsmodell. |
|
||||
| **Begründung** | Familien-Muster aus Mitai; Schutz vor Fehlkonfiguration. |
|
||||
| **Quelle** | Migration 069 |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Keine Historie benutzerdefinierter Änderungen. |
|
||||
|
||||
### 7. Admin-only Schreiben, authentifiziertes Ausführen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Prompt-CRUD nur Admin; Ausführung mit Capability + Feature-Kontingent. |
|
||||
| **Begründung** | Systemkonfiguration vs. Nutzung; Kostenkontrolle. |
|
||||
| **Quelle** | `ai_prompts_admin.py`; `exercises.ai.suggest` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Enforcement teils noch Probe-Phase. |
|
||||
|
||||
### 8. Skill-Retrieval orthogonal zu Prompts
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `ai_skill_retrieval_profiles` steuert Katalog für `{{skills_catalog}}` — unabhängig vom Prompt-Text. |
|
||||
| **Begründung** | Anweisung vs. Kontextfenster trennbar konfigurierbar. |
|
||||
| **Quelle** | `AI_PROMPT_TARGET_ARCHITECTURE.md` §3.3 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen (Shinkan-Ist / Mitai-Vermeidung)
|
||||
|
||||
1. **Direkte OpenRouter-Calls in Routern** ohne Laufzeit-Schicht — historische Schuld, abbauen.
|
||||
2. **Hardcodierte Prompt-Strings in Produktion** — nur Fallback/Dev.
|
||||
3. **Mitai-Workflow-Graph vorreifen** — Shinkan braucht erst Planungs-Kontext-Reife.
|
||||
4. **Globale Platzhalter-Map ohne Namespace** — Mitai-Lektion `PLACEHOLDER_MAP`-Duplikat.
|
||||
5. **Fehlende JSON-Schema-Validierung** bei `output_format=json` — Mitai-TODO übernehmen vermeiden.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [AI_PROMPT_TARGET_ARCHITECTURE.md](../../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md)
|
||||
- [AI_PROMPT_SYSTEM_SPEC.md](../../../.claude/docs/technical/AI_PROMPT_SYSTEM_SPEC.md)
|
||||
- [PLANNING_PROGRESSION_GRAPH_KI.md](../../architecture/PLANNING_PROGRESSION_GRAPH_KI.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
# Auth & Session – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Auth & Session“ — gemeinsame Mitai-Basis in Shinkan
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 4 von 15
|
||||
**Mitai-Vergleich:** [AUTH_SESSION_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/AUTH_SESSION_DESIGN_PRINCIPLES.md) (#5)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Auth-Kern | `backend/auth.py` |
|
||||
| Router | `backend/routers/auth.py`, `profiles.py` |
|
||||
| Frontend | `frontend/src/context/AuthContext.jsx`, `frontend/src/api/client.js` |
|
||||
| Account-Lifecycle | `backend/account_lifecycle.py` |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Auth & Session**
|
||||
|
||||
Token-basierte Server-Sessions (`sessions`-Tabelle), bcrypt-Passwörter, FastAPI-Dependencies `require_auth` / `require_admin`. Geteilter Code mit Mitai (App-Familie).
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
1. **Login/Logout/Session-Lebensdauer**
|
||||
2. **Passwort-Hashing** (bcrypt, Legacy-SHA256-Upgrade)
|
||||
3. **Auth-Dependencies** für Router
|
||||
4. **Account-States** (Verifizierung, Onboarding-Gates)
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Server-Side Sessions mit Token
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `X-Auth-Token` Header → Lookup in `sessions` mit Ablaufzeit. |
|
||||
| **Begründung** | Widerrufbar; kein JWT-Drift zwischen Apps. |
|
||||
| **Quelle** | `auth.py` `get_session`, `require_auth` |
|
||||
| **Tragfähigkeit** | **hoch** (Familien-Standard) |
|
||||
| **Einschränkung** | Kein Refresh-Token-Rotation-Modell. |
|
||||
|
||||
### 2. `require_auth` als separater Depends-Parameter
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `session: dict = Depends(require_auth)` — nie in Header-Default eingebettet. |
|
||||
| **Begründung** | Bekannter FastAPI-Footgun führt zu ungeschützten Endpoints. |
|
||||
| **Quelle** | `CLAUDE.md` Kritische Regeln |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Code-Review/Lint erzwingt das nicht automatisch. |
|
||||
|
||||
### 3. Profile-ID immer aus Session
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `profile_id` aus `session['profile_id']`, nie aus Client-Header als Autorität. |
|
||||
| **Begründung** | IDOR-Vermeidung. |
|
||||
| **Quelle** | Architektur-Regeln; Shinkan ergänzt `TenantContext.profile_id` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Mitai-Dokument nennt Profile-Header-Schwäche — in Shinkan prüfen ob analog. |
|
||||
|
||||
### 4. bcrypt für alle Passwort-Operationen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `hash_pin` / `verify_pin` mit bcrypt; SHA256 nur Legacy-Verify + Upgrade. |
|
||||
| **Begründung** | Familien-konsistente Kryptografie. |
|
||||
| **Quelle** | `auth.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 5. Portal-Rollen vs. Vereinsrollen trennen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `profiles.role` (admin/superadmin/user) ≠ `club_member_roles` im Verein. |
|
||||
| **Begründung** | Shinkan-Mandantenmodell; Plattform-Admin ≠ Vereins-Trainer. |
|
||||
| **Quelle** | `club_tenancy.py`; `TenantContext.global_role` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | UI muss beide Ebenen korrekt anzeigen. |
|
||||
|
||||
### 6. Account-Lifecycle als Capability-Voraussetzung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `min_account_state` auf Capabilities (z. B. verifiziertes Mitglied). |
|
||||
| **Begründung** | Gates vor sensiblen Aktionen ohne Sonderchecks in Routern. |
|
||||
| **Quelle** | `account_lifecycle.py`; `capabilities.py` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Nicht alle Flows nutzen Lifecycle einheitlich. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Auth-Parameter in Header-Defaults vermischen** — dokumentierter Anti-Pattern.
|
||||
2. **Client-gesteuerte `profile_id`** für Autorisierung.
|
||||
3. **Shinkan-spezifische Mandantenlogik in `auth.py`** — gehört in `tenant_context.py`.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [ACCESS_LAYER_DESIGN_PRINCIPLES.md](./ACCESS_LAYER_DESIGN_PRINCIPLES.md)
|
||||
- Mitai: [AUTH_SESSION_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/AUTH_SESSION_DESIGN_PRINCIPLES.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
# Capability & Club Features – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Capabilities & Vereins-Feature-Kontingente“ — nicht Billing/Stripe
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 2 von 15
|
||||
**Mitai-Vergleich:** [FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/FEATURE_ENTITLEMENT_DESIGN_PRINCIPLES.md) (#3)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Capabilities | `backend/capabilities.py` |
|
||||
| Vereins-Features | `backend/club_features.py` |
|
||||
| Entitlements-API | `backend/entitlements.py`, `backend/routers/me_entitlements.py` |
|
||||
| Quota-Bypass | `backend/club_quota_bypass.py` |
|
||||
| Spez | `CAPABILITY_CATALOG.v1.md`, `CLUB_MEMBERSHIP_AND_FEATURES.v1.md` |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Capability & Club Feature Entitlements**
|
||||
|
||||
Zwei Schichten: **Capabilities** (darf Nutzer X im Verein Y?) und **Club Features** (Kontingente/Limits pro Verein, Subjekt `club_id`). Zusammenführung in `GET /api/me/entitlements`.
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
1. **Capability-Checks** — `check_capability`, `probe_capability`, `require_capability` mit Env `CAPABILITY_ENFORCE`.
|
||||
2. **Vereins-Kontingente** — `probe_club_feature_access`, `consume_club_feature_with_usage` mit Env `CLUB_FEATURE_ENFORCE`.
|
||||
3. **Entitlements-Snapshot** — Frontend erhält `capabilities` + `features` + Plan für UI-Gating ohne Tier-Logik in Widgets.
|
||||
4. **Account-Lifecycle** — `min_account_state` blockiert Capabilities vor Verifizierung.
|
||||
|
||||
### Unterschied zu Mitai
|
||||
|
||||
| Aspekt | Mitai | Shinkan |
|
||||
|--------|-------|---------|
|
||||
| Limit-Subjekt | Profil / Subscription | **Verein** (`club_id`) |
|
||||
| Rollen | Tier + Features | Vereinsrollen + Portal-Rolle |
|
||||
| Legacy | `check_feature_access` (001) | Explizit **nicht** für Shinkan-Limits nutzen |
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Eine Entitlements-API für das Frontend
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `GET /api/me/entitlements?club_id=` liefert Capabilities-Map + Feature-Kontingente + Plan. |
|
||||
| **Begründung** | Keine Tier-Logik in React-Komponenten; ein Roundtrip pro Mandantenwechsel. |
|
||||
| **Quelle** | `entitlements.py`, `me_entitlements.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nicht alle UI-Stellen nutzen Entitlements konsequent. |
|
||||
|
||||
### 2. 4-Phasen-Rollout (Probe → Enforce)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Phase 2: JSON-Log ohne Block; Phase 3+: `CAPABILITY_ENFORCE=1` / `CLUB_FEATURE_ENFORCE=1` → HTTP 403. |
|
||||
| **Begründung** | Sicheres Einführen ohne Produktions-Crash; Audit vor Hard-Block. |
|
||||
| **Quelle** | `capabilities.py`, `club_features.py`; Mitai-Vorbild in Spec |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Env-Flags müssen pro Umgebung bewusst gesetzt werden. |
|
||||
|
||||
### 3. Capabilities verknüpft mit Features
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Capability kann `linked_feature_id` haben — Kontingent-Check vor Ausführung. |
|
||||
| **Begründung** | Recht und Limit bleiben getrennt modelliert aber gemeinsam enforcebar. |
|
||||
| **Quelle** | `capabilities`-Tabelle; `check_capability` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Nicht jede Capability hat linked Feature. |
|
||||
|
||||
### 4. Enforcement an der API, nicht in der UI
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Router rufen `require_capability` / `probe_club_feature_access` — UI blendet nur vor. |
|
||||
| **Begründung** | API ist Source of Truth; UI-Gating allein ist umgehbar. |
|
||||
| **Quelle** | `exercise_ai.py`, Planungs-KI-Router |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Teilweise noch Probe-only in Prod. |
|
||||
|
||||
### 5. Bestands-Features als Live-Zählung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Inventar-Features (`exercises`, `training_groups`, …) zählen live in DB, nicht nur `club_feature_usage`. |
|
||||
| **Begründung** | Keine Drift zwischen tatsächlichem Bestand und Usage-Tabelle. |
|
||||
| **Quelle** | `_INVENTORY_FEATURES` in `club_features.py` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Performance bei großen Vereinen — ggf. Cache nötig. |
|
||||
|
||||
### 6. Quota-Bypass für Plattform-Rollen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Konfigurierbare Bypass-Capabilities (`domain=quota_bypass`) für Support/Admin ohne harte Limits. |
|
||||
| **Begründung** | Betrieb und Demos ohne Plan-Upgrade. |
|
||||
| **Quelle** | `club_quota_bypass.py`; `entitlements.py` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Missbrauchsrisiko — nur dokumentierte Grants. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Mitai `auth.check_feature_access` für Shinkan-Vereinslimits** — profil-zentriert, falscher Subjekt-Scope.
|
||||
2. **Tier-Logik in Frontend-Widgets** — gehört in Entitlements-Response.
|
||||
3. **Enforcement ohne Probe-Phase** — bricht bestehende Vereine ohne Vorwarnung.
|
||||
4. **Capabilities ohne DB-Sync aus Registry** — siehe Rights-Registry-Dokument.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [CAPABILITY_CATALOG.v1.md](../../../.claude/docs/technical/CAPABILITY_CATALOG.v1.md)
|
||||
- [CLUB_MEMBERSHIP_AND_FEATURES.v1.md](../../../.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md)
|
||||
- [RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md](./RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
# Content Reports (P-13) – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Content Reports“ — Meldeverfahren, Posteingang, Legal Hold
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 10 von 15
|
||||
**Mitai-Vergleich:** — (Compliance-spezifisch)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| API | `backend/routers/content_reports.py` |
|
||||
| Legal Hold | `backend/media_legal_hold.py` |
|
||||
| Inbox-Integration | `GET /api/me/inbox/content-reports` |
|
||||
| Frontend | `InboxPage.jsx` |
|
||||
| Migration | 052, 053 |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Content Reports & Compliance (P-13)**
|
||||
|
||||
Meldeverfahren für problematische Inhalte (Medien, Übungen), Admin-Posteingang mit Statusworkflow, Priorisierung sensibler Gründe, Anbindung Legal Hold und Medien-Audit.
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Eine Tabelle, ein Workflow — keine separate Admin-Queue
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `content_reports` + bestehende Inbox-UI für Änderungsanfragen und Meldungen. |
|
||||
| **Begründung** | Admin-Arbeit an einem Ort; weniger Navigations-Fragmentierung. |
|
||||
| **Quelle** | `content_reports.py` Docstring |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Zwei Vorgangstypen in einer UI — klare Typ-Kennzeichnung nötig. |
|
||||
|
||||
### 2. Melden optional ohne Auth (eingeschränkt)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Anonym/offline Meldung für `official`-Medien erlaubt; sonst Auth empfohlen. |
|
||||
| **Begründung** | DSA-Anforderungen; öffentliche Plattform-Inhalte meldbar. |
|
||||
| **Quelle** | Router-Berechtigungen |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Missbrauchsschutz (Rate Limit) prüfen. |
|
||||
|
||||
### 3. Priorität bei sensiblen Gründen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `minors`, `illegal_content`, `youth_protection` → HIGH_PRIORITY automatisch. |
|
||||
| **Begründung** | SLA und Admin-Aufmerksamkeit fachlich korrekt. |
|
||||
| **Quelle** | `HIGH_PRIORITY_REASONS` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 4. Rollengetrennte Sicht (Plattform vs. Verein)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Plattform-Admin: alle; Club-Admin: nur Vereinsmedien-Meldungen. |
|
||||
| **Begründung** | Mandanten-Grenze auch im Compliance-Kontext. |
|
||||
| **Quelle** | Router-Listenfilter |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 5. Legal Hold nur Superadmin aus Meldung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Mapping `report_reason` → `legal_hold reason_code`; `set_legal_hold` superadmin-geschützt. |
|
||||
| **Begründung** | Hochrisiko-Aktion; Anschluss P-11. |
|
||||
| **Quelle** | `_REASON_TO_HOLD_CODE`; `media_legal_hold.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 6. Audit-Spur bei Medien-Meldungen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `media_asset_audit_log` Event `content_report_filed` bei media_asset-Bezug. |
|
||||
| **Begründung** | Lifecycle-Entscheidungen nachvollziehbar. |
|
||||
| **Quelle** | Migration 053; `write_audit_log_entry` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 7. E-Mail best-effort, kein Hard-Fail
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Bestätigung an Melder + Admin-Benachrichtigung; SMTP-Fehler blockieren Speichern nicht. |
|
||||
| **Begründung** | Meldung geht nicht verloren wenn Mail down. |
|
||||
| **Quelle** | Router-Implementierung |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Admins müssen Posteingang auch ohne Mail prüfen. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Separate Compliance-Queue-App** — Inbox-Reuse ist bewusst.
|
||||
2. **Legal Hold durch Club-Admin** — Superadmin-only.
|
||||
3. **Meldungen ohne Statusworkflow/Archiv** — Wiedereröffnen muss möglich sein.
|
||||
4. **Fehlende Verknüpfung Medien-Lifecycle** — Hold muss Purge blockieren.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [MEDIA_ASSETS_DESIGN_PRINCIPLES.md](./MEDIA_ASSETS_DESIGN_PRINCIPLES.md)
|
||||
- [ACCESS_LAYER_DESIGN_PRINCIPLES.md](./ACCESS_LAYER_DESIGN_PRINCIPLES.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
# Dashboard KPIs – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Dashboard KPI Aggregation“ — vereinfacht vs. Mitai Widgets
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 14 von 15
|
||||
**Mitai-Vergleich:** [DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md) (#7, vereinfacht)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| API | `backend/routers/dashboard.py` — `GET /api/dashboard/kpis` |
|
||||
| Frontend | Dashboard/Übersicht-Page |
|
||||
| Refaktor-Kontext | `docs/architecture/SCHULDEN_UND_REMEDIATION.md` A3, B1 |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Dashboard KPI Aggregation**
|
||||
|
||||
Ein Backend-Roundtrip liefert Übungs-KPIs, YTD-Einheiten, Trainings-Home (nächste Termine, Vermerke, offene Rückschau) — Ersatz für mehrere parallele Client-Listen-Calls.
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Aggregierter Endpoint statt Chatty Client
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `GET /dashboard/kpis` ruft intern `list_exercises_like_get` + `list_training_units` mit gleichen Filtern wie zuvor im UI. |
|
||||
| **Begründung** | Weniger Latenz; eine TenantContext-Auflösung; Refaktor Phase 1 Dashboard. |
|
||||
| **Quelle** | `dashboard.py`; SCHULDEN A3/B1 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Noch keine konfigurierbaren Widgets wie Mitai. |
|
||||
|
||||
### 2. Gleiche Filtersemantik wie Einzel-Endpoints
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | KPI-Zählungen nutzen dieselben Helfer wie `/exercises` und `/training-units` — keine zweite Query-Logik. |
|
||||
| **Begründung** | Zahlen auf Dashboard = Zahlen in Fachmodulen. |
|
||||
| **Quelle** | Import aus `exercises`, `training_planning` Routern |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Interne Funktionsaufrufe statt HTTP — Kopplung an Router-Helfer. |
|
||||
|
||||
### 3. TenantContext für Mandanten-KPIs
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `Depends(get_tenant_context)` — assigned_to_me, created_by_me respektieren Verein/Rolle. |
|
||||
| **Begründung** | Keine globalen KPIs für Trainer fremder Vereine. |
|
||||
| **Quelle** | `get_dashboard_kpis` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 4. Festes Dashboard-Layout (kein Widget-Katalog)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Shinkan-Übersicht zeigt definierte Kacheln — nicht nutzerkonfigurierbares Layout-JSON. |
|
||||
| **Begründung** | MVP-Fokus Trainer-Verein; weniger Komplexität als Mitai. |
|
||||
| **Quelle** | Produktentscheidung vs. Mitai #7 |
|
||||
| **Tragfähigkeit** | **mittel** (bewusste Vereinfachung) |
|
||||
| **Einschränkung** | Erweiterung braucht Backend+Frontend-Change, nicht Admin-Config. |
|
||||
|
||||
### 5. Profil nicht redundant laden
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Dashboard soll Auth-Profil nutzen — kein zweites `/profiles/me` nach Login+Reload (E2E Test 8). |
|
||||
| **Begründung** | Architekturschuld A3 explizit adressiert. |
|
||||
| **Quelle** | `tests/dev-smoke-test.spec.js`; Roadmap |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Frontend-Umsetzung muss mitziehen. |
|
||||
|
||||
### 6. Slice-Logik für Trainings-Home im Backend
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `_slice_training_home_notes` filtert Einheiten mit Vermerken — max. N Stück serverseitig. |
|
||||
| **Begründung** | Kleiner Payload; klare Semantik. |
|
||||
| **Quelle** | `dashboard.py` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Grenzwert hardcoded — ggf. Query-Param später. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Drei parallele fast gleiche `listTrainingUnits`-Calls** im Client — behoben durch KPI-Endpoint.
|
||||
2. **Mitai Widget-Dual-Registry vorreifen** ohne Produktbedarf — Over-Engineering für Shinkan MVP.
|
||||
3. **KPI-Berechnung im Frontend** aus Volllisten — skaliert nicht.
|
||||
4. **Dashboard ohne Tenant-Filter** — Mandanten-Leak.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [docs/architecture/SCHULDEN_UND_REMEDIATION.md](../../architecture/SCHULDEN_UND_REMEDIATION.md)
|
||||
- Mitai: [DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
# Designprinzipien – Index (Jinkendo Produktfamilie)
|
||||
|
||||
**Status:** Arbeitspapier / Übergabe
|
||||
**Stand:** 2026-07-04
|
||||
**Zweck:** Zentraler Einstieg für **tragfähige Designprinzipien** der Jinkendo-Produktfamilie — Shinkan-Serie (15 Module), Abgleich mit Mitai (9 Module), Basis für Schwester-Apps.
|
||||
|
||||
**Nicht enthalten:** App-Gesamtarchitektur, domänenspezifische Fachlogik im Detail, vollständige API-Referenz.
|
||||
|
||||
**Ablage (Shinkan-Serie):** `docs/jinkendo-family/design-principles/*_DESIGN_PRINCIPLES.md`
|
||||
**Übergeordnet:** [docs/jinkendo-family/README.md](../README.md)
|
||||
**Mitai-Serie:** [mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/](file:///c:/Dev/mitai-jinkendo/.claude/docs/jinkendo-foundation/design-principles/README.md)
|
||||
**Abgleich Mitai ↔ Shinkan:** [DESIGN_PRINCIPLES_ALIGNMENT.md](../DESIGN_PRINCIPLES_ALIGNMENT.md)
|
||||
|
||||
---
|
||||
|
||||
## Wofür diese Serie?
|
||||
|
||||
Shinkan implementiert wiederkehrende **Querschnittsmuster** (Mandanten-Zugriff, Capabilities, Medien-Archiv, Planungsdomäne, KI-Laufzeit, Import, Navigation, Deploy) sowie **domänenspezifische Bausteine** (Übungskatalog, Fähigkeiten-Scoring, Trainingsplanung, Compliance-Meldungen). Die 15 Dokumente destillieren daraus:
|
||||
|
||||
- **Was** übertragbar ist (Prinzip + Begründung + Tragfähigkeit)
|
||||
- **Was** bewusst nicht kopiert werden soll („Nicht übernehmen“)
|
||||
- **Wo** im Code nachgeschaut werden kann (Pfade, Specs)
|
||||
- **Mitai-Abgleich** — welches Schwester-Dokument vergleichbar ist
|
||||
|
||||
Jedes Dokument ist **eigenständig lesbar**; dieser Index ordnet Abhängigkeiten, Lese-Reihenfolge und den geplanten Familien-Review.
|
||||
|
||||
---
|
||||
|
||||
## Dokumente (15/15)
|
||||
|
||||
| # | Modul | Datei | Kernidee (1 Satz) | Mitai-Vergleich |
|
||||
|---|-------|-------|-------------------|-----------------|
|
||||
| 1 | Access Layer & Tenant | [ACCESS_LAYER_DESIGN_PRINCIPLES.md](./ACCESS_LAYER_DESIGN_PRINCIPLES.md) | Ein `TenantContext` pro Request; einheitliche `visibility`/`club_id`-Semantik für Bibliotheksartefakte. | — (Shinkan-spezifisch) |
|
||||
| 2 | Capability & Club Features | [CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md](./CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md) | Capabilities + Vereins-Kontingente; `GET /me/entitlements`; 4-Phasen-Rollout mit Env-Flags. | #3 Feature & Entitlement |
|
||||
| 3 | Rights Registry | [RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md](./RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md) | Module registrieren Capabilities/Features bei Startup — kein vollständiger Vorab-Katalog in Migrationen. | #4 Registry / Plugin |
|
||||
| 4 | Auth & Session | [AUTH_SESSION_DESIGN_PRINCIPLES.md](./AUTH_SESSION_DESIGN_PRINCIPLES.md) | Server-Sessions, `require_auth` als Depends; gemeinsame Mitai-Basis. | #5 Auth & Session |
|
||||
| 5 | AI Prompt Runtime | [AI_PROMPT_RUNTIME_DESIGN_PRINCIPLES.md](./AI_PROMPT_RUNTIME_DESIGN_PRINCIPLES.md) | Schmale Laufzeit (`ai_prompt_runtime`); DB-Templates + Mustache; Kontext-Arten pro Domäne. | #1 Prompt Engine |
|
||||
| 6 | Media Assets & Archiv | [MEDIA_ASSETS_DESIGN_PRINCIPLES.md](./MEDIA_ASSETS_DESIGN_PRINCIPLES.md) | Physisches Asset einmal, mehrfach verknüpft; Lifecycle, Legal Hold, Inline-Rich-Text. | — |
|
||||
| 7 | Exercise Catalog | [EXERCISE_CATALOG_DESIGN_PRINCIPLES.md](./EXERCISE_CATALOG_DESIGN_PRINCIPLES.md) | Übung als Kernobjekt; Varianten, Governance, Progressionsgraph, Kombinationsübungen. | — |
|
||||
| 8 | Skill Scoring | [SKILL_SCORING_DESIGN_PRINCIPLES.md](./SKILL_SCORING_DESIGN_PRINCIPLES.md) | Regelbasiertes gewichtetes Profil; Peer-Vergleich nur unter gleichem Artefakttyp. | #2 Data Layer (teilweise) |
|
||||
| 9 | Training Planning | [TRAINING_PLANNING_DESIGN_PRINCIPLES.md](./TRAINING_PLANNING_DESIGN_PRINCIPLES.md) | Einheiten mit Phasen/Streams; Rahmen-Bibliothek + Module; Coach/Durchführung getrennt. | — |
|
||||
| 10 | Content Reports (P-13) | [CONTENT_REPORTS_DESIGN_PRINCIPLES.md](./CONTENT_REPORTS_DESIGN_PRINCIPLES.md) | Melde-Workflow in Posteingang; Priorität sensibler Gründe; Legal-Hold-Anschluss. | — |
|
||||
| 11 | Wiki Import | [WIKI_IMPORT_DESIGN_PRINCIPLES.md](./WIKI_IMPORT_DESIGN_PRINCIPLES.md) | SMW-API-Ingest + Mapper; Preview/Dry-Run; Duplikat-Tracking — kein Raw-Wiki in DB. | #6 Universal Import |
|
||||
| 12 | Navigation / IA | [NAVIGATION_IA_DESIGN_PRINCIPLES.md](./NAVIGATION_IA_DESIGN_PRINCIPLES.md) | `appNav.js` als SSoT; Admin-Hub horizontal; Onboarding-Nav ohne Verein. | #8 Navigation / IA |
|
||||
| 13 | Migration & Deploy | [MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md](./MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md) | `XXX_*.sql` beim Container-Start; develop/main → Dev/Prod; fail-fast. | #9 Migration & Deploy |
|
||||
| 14 | Dashboard KPIs | [DASHBOARD_KPI_DESIGN_PRINCIPLES.md](./DASHBOARD_KPI_DESIGN_PRINCIPLES.md) | Aggregierter `/dashboard/kpis`-Roundtrip statt mehrerer Listen-Calls. | #7 Dashboard Widgets (vereinfacht) |
|
||||
| 15 | Maturity Models | [MATURITY_MODELS_DESIGN_PRINCIPLES.md](./MATURITY_MODELS_DESIGN_PRINCIPLES.md) | Kontextsensitive Matrix-Auflösung; Export/Import-Stack für Admin-Portabilität. | — |
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Lesereihenfolge
|
||||
|
||||
### Schnellüberblick (45 Min)
|
||||
|
||||
1. [ACCESS_LAYER_DESIGN_PRINCIPLES.md](./ACCESS_LAYER_DESIGN_PRINCIPLES.md) — Shinkan-Kernunterscheidung zu Mitai
|
||||
2. [RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md](./RIGHTS_REGISTRY_DESIGN_PRINCIPLES.md) — Meta-Muster für Erweiterbarkeit
|
||||
3. [MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md](./MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md) — Familien-Basis
|
||||
|
||||
### Vollständige Implementierung (neues Produkt)
|
||||
|
||||
```
|
||||
Foundation: (13) Migration & Deploy → (4) Auth → (1) Access Layer → (2) Capabilities → (3) Registry
|
||||
Domäne: (7) Exercise Catalog → (6) Media → (9) Training Planning → (8) Skill Scoring
|
||||
Erweiterung: (5) AI Prompt Runtime → (11) Wiki Import → (15) Maturity Models
|
||||
Compliance: (10) Content Reports
|
||||
Oberfläche: (12) Navigation → (14) Dashboard KPIs
|
||||
```
|
||||
|
||||
### Nur Familien-Review (Mitai ↔ Shinkan)
|
||||
|
||||
| Mitai-Dokument | Shinkan-Gegenstück | Review-Fokus |
|
||||
|----------------|-------------------|--------------|
|
||||
| #1 Prompt Engine | #5 AI Prompt Runtime | Executor-Reife, Registry, Workflows |
|
||||
| #2 Data Layer | #8 Skill Scoring | Berechnungs-SSoT vs. Router-Duplikat |
|
||||
| #3 Feature & Entitlement | #2 Capability & Club Features | Subjekt: Profil vs. Verein |
|
||||
| #4 Registry | #3 Rights Registry | Registrierungsmuster |
|
||||
| #5 Auth | #4 Auth & Session | Gemeinsamer Code, IDOR-Risiken |
|
||||
| #6 Universal Import | #11 Wiki Import | Ingest ≠ Interpretation |
|
||||
| #7 Dashboard Widgets | #14 Dashboard KPIs | Konfigurierbarkeit vs. Aggregation |
|
||||
| #8 Navigation | #12 Navigation | appNav-Pattern |
|
||||
| #9 Migration & Deploy | #13 Migration & Deploy | Gleiches Startup-Muster |
|
||||
|
||||
---
|
||||
|
||||
## Querschnittsthemen (über alle Docs)
|
||||
|
||||
| Thema | Primär | Ergänzend |
|
||||
|-------|--------|-----------|
|
||||
| Mandanten-Isolation (`club_id`) | #1 Access Layer | #6 Media, #7 Exercise, #9 Planning |
|
||||
| Sichtbarkeit `private`/`club`/`official` | #1 Access Layer | #6 Media, #7 Exercise |
|
||||
| Capability-Gating | #2 Entitlement | #3 Registry, #5 AI Runtime |
|
||||
| Validierung an der Grenze | #3 Registry | #6 Inline-Media, #11 Import-Mapper |
|
||||
| Single Source of Truth (Berechnung) | #8 Skill Scoring | #5 AI Kontext-Builder |
|
||||
| Dual Registry (Code + DB) | #3 Rights Registry | #2 Capabilities in DB |
|
||||
| Bekannte Lücken dokumentieren | alle „Nicht übernehmen“ | Endpoint-Audit, Architekturschuld |
|
||||
|
||||
---
|
||||
|
||||
## Verwandte normative Docs (Shinkan-spezifisch)
|
||||
|
||||
| Thema | Agent-Guide / Spec |
|
||||
|-------|-------------------|
|
||||
| Zugriffsschicht | [ACCESS_LAYER_AND_GOVERNANCE_PLAN.md](../../../.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md), [ACCESS_LAYER_ENDPOINT_AUDIT.md](../../../.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md) |
|
||||
| Capabilities | [CAPABILITY_CATALOG.v1.md](../../../.claude/docs/technical/CAPABILITY_CATALOG.v1.md) |
|
||||
| Vereins-Features | [CLUB_MEMBERSHIP_AND_FEATURES.v1.md](../../../.claude/docs/technical/CLUB_MEMBERSHIP_AND_FEATURES.v1.md) |
|
||||
| Medien | [MEDIA_ASSETS_AND_ARCHIVE_SPEC.md](../../../.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) |
|
||||
| KI-Zielbild | [AI_PROMPT_TARGET_ARCHITECTURE.md](../../../.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md) |
|
||||
| Planung Streams | [PARALLEL_TRAINING_STREAMS_SPEC.md](../../../.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md) |
|
||||
| Skill Scoring | [SKILL_SCORING_SPEC.md](../../../.claude/docs/technical/SKILL_SCORING_SPEC.md) |
|
||||
| Architektur-Schuld | [docs/architecture/SCHULDEN_UND_REMEDIATION.md](../../architecture/SCHULDEN_UND_REMEDIATION.md) |
|
||||
|
||||
---
|
||||
|
||||
## Übergabe-Checkliste (Familien-Review)
|
||||
|
||||
```
|
||||
[ ] Pro Modul: Prinzipien vs. Mitai-Gegenstück abgleichen
|
||||
[ ] Architekturschuld pro Modul in SCHULDEN_UND_REMEDIATION / „Nicht übernehmen“ verknüpfen
|
||||
[ ] Gemeinsame Familien-Prinzipien aus Übereinstimmungen ableiten
|
||||
[ ] Abweichungen bewusst dokumentieren (z. B. Vereins- vs. Profil-Entitlements)
|
||||
[ ] Shared Code (auth.py, db_init) — eine Quelle oder Fork-Drift?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pflege
|
||||
|
||||
| Aktion | Wo |
|
||||
|--------|-----|
|
||||
| Neues Querschnittsmodul extrahiert | Neues `*_DESIGN_PRINCIPLES.md` + Zeile in Tabelle oben |
|
||||
| Shinkan-Implementierung ändert Muster | Betroffenes Einzeldokument + ggf. Querschnittstabelle |
|
||||
| Mitai-Review abgeschlossen | Abschnitt „Familien-Prinzipien“ (separates Doc, Backlog) |
|
||||
|
||||
---
|
||||
|
||||
## Changelog Index
|
||||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 2026-07-04 | Verweis auf [FAMILY_ENTITLEMENT_MODEL.md](../FAMILY_ENTITLEMENT_MODEL.md) |
|
||||
| 2026-07-04 | Verweis auf [DESIGN_PRINCIPLES_ALIGNMENT.md](../DESIGN_PRINCIPLES_ALIGNMENT.md) |
|
||||
| 2026-07-04 | Shinkan-Serie nach `docs/jinkendo-family/design-principles/` verschoben (Familien-Foundation) |
|
||||
| 2026-07-04 | Index angelegt; Serie 1–15 aus Shinkan-Ist-Stand extrahiert |
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
# Exercise Catalog – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Exercise Catalog“ — Kernobjekt Übung, Varianten, Graphen, Kombination
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 7 von 15
|
||||
**Mitai-Vergleich:** — (domänenspezifisch)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| API | `backend/routers/exercises.py`, `exercise_progression_graphs.py` |
|
||||
| Rich-Text | `backend/exercise_rich_text.py` |
|
||||
| KI | `backend/exercise_ai.py` |
|
||||
| Frontend | `frontend/src/pages/Exercises*.jsx`, Tab-Formular |
|
||||
| Specs | `EXERCISES_ARCHITECTURE.md`, `EXERCISES_API_SPEC.md`, Kombinations-Spec |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Exercise Catalog**
|
||||
|
||||
Shinkans Kernobjekt: Übungen mit mehrdimensionaler Einordnung (Skills, Fokus, Stile), Varianten, Progressionsgraphen, Kombinationsübungen (`method_archetype`, Stationen), Governance und Medien-Anbindung.
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Übung als zentrales Aggregate Root
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Varianten, Medien, Skills, Graph-Knoten hängen an `exercises.id` — Owner/Governance auf Eltern-Übung. |
|
||||
| **Begründung** | Eine Freigabe- und Lösch-Semantik; Varianten ohne eigenen Owner. |
|
||||
| **Quelle** | `FACHLICHE_NUTZERFUNKTIONEN.md` §4.1, §4.7 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Große Monolith-Router/Seiten — Refaktor-Roadmap. |
|
||||
|
||||
### 2. Tab-Formular statt Scroll-Monolith
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Register: Stammdaten · Anleitung · Einordnung · Kombination · Varianten · Medien — Varianten/Medien erst nach erstem Save. |
|
||||
| **Begründung** | UX für komplexe Objekte; klare Abhängigkeiten (IDs für Medien/Varianten). |
|
||||
| **Quelle** | Nutzerfunktionen §4.1 |
|
||||
| **Tragfähigkeit** | **hoch** (UI-Muster) |
|
||||
| **Einschränkung** | Frontend-God-Page-Schuld dokumentiert. |
|
||||
|
||||
### 3. Mehrdimensionale Filter-SSoT
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Suche/Filter über Skills, Fokus, Stil, Zielgruppe, Status, Freigabelevel — Backend-Query + gespeicherte Präferenzen. |
|
||||
| **Begründung** | Trainer finden Inhalte in großen Vereins-Katalogen. |
|
||||
| **Quelle** | `SEARCH_FILTER_SPEC.md`; `exercises.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Performance schwere Listen — Baseline/Roadmap. |
|
||||
|
||||
### 4. Varianten mit Voraussetzungskette
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `exercise_variants` mit Reihenfolge, optional `prerequisite_variant_id`. |
|
||||
| **Begründung** | Didaktische Abstufung innerhalb einer Übung. |
|
||||
| **Quelle** | Migration 030; Planung nutzt Varianten-ID pro Eintrag |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 5. Progressionsgraph als gerichtete Übungs-Beziehungen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Knoten = Übungen/Varianten; Kanten = „weiter“-Beziehungen; eigener Router + UI in Übungswelt. |
|
||||
| **Begründung** | Didaktik und Planungs-KI nutzen denselben Graph. |
|
||||
| **Quelle** | `exercise_progression_graphs.py`; `PLANNING_PROGRESSION_GRAPH_KI.md` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Graph-Editor-Komplexität; KI-Artefakte separat. |
|
||||
|
||||
### 6. Kombinationsübungen als Sonderform im gleichen Katalog
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `exercise_type=combination` mit Stationen, `method_archetype`, optionalem `method_profile`. |
|
||||
| **Begründung** | In Planung wie normale Übung; Coach zeigt Stations-Layer. |
|
||||
| **Quelle** | Migration 056/057; Kombinations-Spec V2 |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Archetyp-Stufen B/C noch ausbaubar. |
|
||||
|
||||
### 7. Governance integriert (nicht separates CMS)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `visibility`, `status` (draft/review/…), Access-Layer-Lösch/Transition-Regeln. |
|
||||
| **Begründung** | Trainer-Workflow ohne externes Freigabe-Tool. |
|
||||
| **Quelle** | `club_tenancy.py`; Content Change Requests → Posteingang |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Formales Review-Workflow noch leichtgewichtig. |
|
||||
|
||||
### 8. Rich-Text-Felder mit Inline-Medien
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Einheitliche Platzhalter/Render für `summary`, `goal`, `execution`, … |
|
||||
| **Begründung** | Medienreicher Inhalt ohne iframe-Split. |
|
||||
| **Quelle** | `exercise_rich_text.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Varianten mit eigenem Owner/Freigabe** — widerspricht Domänenmodell.
|
||||
2. **Übungsliste ohne Tenant-Filter** — Access-Layer-Pflicht.
|
||||
3. **KI-generierte Übungen ohne Governance-Felder** — immer draft/private Default.
|
||||
4. **Progressionsgraph-Logik im Frontend allein** — Server validiert Kanten.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [EXERCISES_ARCHITECTURE.md](../../../.claude/docs/technical/EXERCISES_ARCHITECTURE.md)
|
||||
- [MEDIA_ASSETS_DESIGN_PRINCIPLES.md](./MEDIA_ASSETS_DESIGN_PRINCIPLES.md)
|
||||
- [SKILL_SCORING_DESIGN_PRINCIPLES.md](./SKILL_SCORING_DESIGN_PRINCIPLES.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
# Maturity Models – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Maturity Models / Fähigkeitsmatrix“ — kontextsensitive Auflösung
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 15 von 15
|
||||
**Mitai-Vergleich:** — (domänenspezifisch)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| API | `backend/routers/maturity_models.py`, `matrix_editor.py`, `matrix_stack_bundle.py` |
|
||||
| Admin-UI | `/admin/maturity-models` |
|
||||
| Import | Wiki-Import Typ Modelle; Matrix-Stack Export/Import |
|
||||
| Spec | `.claude/docs/technical/SKILLS_MATRIX_SPEC.md` |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Maturity Models & Matrix Stack**
|
||||
|
||||
Matrixbasierte Reifegradmodelle mit Stufen und Zelltexten; kontextsensitive Auflösung über Bindings (Fokusbereich, Stilrichtung, Zielgruppe); Admin-Export/Import einzelner Modelle und Komplett-Stack.
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Kontext-Bindings M:N (leer = überall)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Modell verknüpft mit Fokus/Stil/Zielgruppe; leere Bindings = global gültig. |
|
||||
| **Begründung** | Ein Stack deckt mehrere Trainingskontexte ab. |
|
||||
| **Quelle** | `maturity_models.py` `_attach_context` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Auflösungs-Priorität bei mehreren Treffern dokumentieren. |
|
||||
|
||||
### 2. Resolve-API für Laufzeit-Nutzung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Authentifizierte Nutzer listen/auflösen; Admin-only für Roh-ID-GET in Admin-UI. |
|
||||
| **Begründung** | Trainer sehen passende Matrix; Rohdaten-Edit geschützt. |
|
||||
| **Quelle** | Router-Docstring; Rollen-Checks |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 3. Matrix-Editor als separates Admin-Tool
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `matrix_editor` Router für Zellbearbeitung — nicht im Trainer-Flow. |
|
||||
| **Begründung** | Komplexe UI; Plattform-Redaktionsaufgabe. |
|
||||
| **Quelle** | Admin-Nav „Fähigkeitsmatrix“ |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Frontend-Komplexität — eigene Schuld-Kategorie. |
|
||||
|
||||
### 4. Stack-Bundle Export/Import
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `matrix_stack_bundle` — Komplett-Stack zwischen Umgebungen (Dev→Prod, Backup). |
|
||||
| **Begründung** | Analog Prompt Import/Export — Konfiguration versionierbar außerhalb DB. |
|
||||
| **Quelle** | Admin-Werkzeuge; Wiki-Import ergänzt |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Kein Diff/Merge wie Mitai Prompt-Import. |
|
||||
|
||||
### 5. Plattform-Admin-Schreibschutz
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Schreiben nur `admin`/`superadmin`; Lesen breiter für authentifizierte Nutzer (Resolve). |
|
||||
| **Begründung** | Offizielle Kompetenzrahmen zentral gepflegt. |
|
||||
| **Quelle** | `_require_admin` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 6. Integration Wiki-Import für Modelle
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | SMW-Kategorie Modelle → Import-Pfad neben Übungen/Skills. |
|
||||
| **Begründung** | Bestehende Wissensbasis karatetrainer.net nutzen. |
|
||||
| **Quelle** | `import_wiki.py` `CATEGORY_MODELS` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Gap-Analyse SMW — nicht alle Wiki-Felder gemappt. |
|
||||
|
||||
### 7. Orthogonal zu Skill Scoring
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Matrix = beschreibende Stufen; Skill Scoring = gewichtete Übungs-Aggregation — getrennte Module. |
|
||||
| **Begründung** | Keine Vermischung von Kompetenz-Raster und Trainings-KPI. |
|
||||
| **Quelle** | Domänen-Trennung in Specs |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | UI kann beides nebenan zeigen — klare Labels nötig. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Matrix-Zellen in Übungs-Score-Formel mischen** — ohne fachliche Spec.
|
||||
2. **Trainer-Edit globaler offizieller Matrizen** — Admin-only.
|
||||
3. **Import ohne Stack-Integrität** — Bundle-Validierung beachten.
|
||||
4. **Resolve ohne Kontext-Parameter** wenn Mehrdeutigkeit — falsche Matrix.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [SKILLS_MATRIX_SPEC.md](../../../.claude/docs/technical/SKILLS_MATRIX_SPEC.md)
|
||||
- [WIKI_IMPORT_DESIGN_PRINCIPLES.md](./WIKI_IMPORT_DESIGN_PRINCIPLES.md)
|
||||
- [SKILL_SCORING_DESIGN_PRINCIPLES.md](./SKILL_SCORING_DESIGN_PRINCIPLES.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
# Media Assets & Archiv – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Media Assets & Archiv“ — physische Medien, Lifecycle, Inline
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 6 von 15
|
||||
**Mitai-Vergleich:** — (Mitai hat kein vergleichbares Medien-Archiv)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| API | `backend/routers/media_assets.py`, `platform_media_storage.py` |
|
||||
| Speicher | `backend/media_storage.py`, `MEDIA_ROOT` |
|
||||
| Rechte/Audit | `backend/media_rights.py`, `media_legal_hold.py` |
|
||||
| Inline Rich-Text | `backend/exercise_rich_text.py` |
|
||||
| Retention-Job | `backend/scripts/media_retention_job.py` |
|
||||
| Spec | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Media Assets & Archiv**
|
||||
|
||||
Zentrale Verwaltung physischer Dateien (`media_assets`), Verknüpfung zu Übungen (`exercise_media`), mehrstufiger Lifecycle (Papierkorb, Legal Hold), Inline-Einbettung in Rich-Text.
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Physisches Asset einmal, mehrfach verknüpft
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Datei in `media_assets`; Übungen referenzieren via `exercise_media.media_asset_id`. |
|
||||
| **Begründung** | Keine Dubletten auf Platte; Wiederverwendung im Archiv. |
|
||||
| **Quelle** | `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §1 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Legacy-Pfade ohne Asset-ID können noch existieren. |
|
||||
|
||||
### 2. Gleiche Visibility-Semantik wie Übungen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `private`/`club`/`official` + `club_id` — Access Layer für Download und Liste. |
|
||||
| **Begründung** | Ein Freigabemodell für alle Bibliotheksartefakte. |
|
||||
| **Quelle** | Spec §4; `media_rights.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Promotion Übung→official muss Assets mithheben — UI-Dialog Pflicht. |
|
||||
|
||||
### 3. Lifecycle getrennt von Übungs-Verknüpfung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Verknüpfung in Übung lösen ≠ Asset physisch löschen; Papierkorb-Stufen separat. |
|
||||
| **Begründung** | Trainer dürfen Link entfernen ohne Archiv-Löschrecht. |
|
||||
| **Quelle** | Spec §5 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Retention-Job muss in Betrieb überwacht werden. |
|
||||
|
||||
### 4. Legal Hold blockiert automatisierten Lifecycle
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `legal_hold` schützt Asset vor Purge; Anbindung an Content Reports (Superadmin). |
|
||||
| **Begründung** | Compliance bei Meldungen (P-11/P-13). |
|
||||
| **Quelle** | `media_legal_hold.py`; `content_reports.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 5. Inline-Medien: kanonisches Markup + Validierung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `{{exerciseMedia:id}}` → `<span data-shinkan-exercise-media="id">`; IDs müssen zur Übung gehören. |
|
||||
| **Begründung** | Ein Render-Pfad; keine broken References nach Medien-Löschung. |
|
||||
| **Quelle** | `exercise_rich_text.py`; Spec §11 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Beim Erst-Anlegen der Übung keine Inline-Refs (Chicken-Egg). |
|
||||
|
||||
### 6. Speicher-Abstraktion (local + konfigurierbarer Root)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `get_effective_media_root()` + `library/…`-Pfadkonvention; kein hardcodierter `/app/media` in Routern. |
|
||||
| **Begründung** | NAS/externer Speicher vorbereitet. |
|
||||
| **Quelle** | `media_storage.py`; `platform_media_storage` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | S3-Backend noch nicht vollständig. |
|
||||
|
||||
### 7. Audit-Log für sensitive Aktionen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `media_asset_audit_log` bei Meldungen, Hold, kritischen Lifecycle-Events. |
|
||||
| **Begründung** | Nachvollziehbarkeit für Admins und Compliance. |
|
||||
| **Quelle** | `media_rights.write_audit_log_entry` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Nicht jede Admin-Aktion geloggt. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Download nur mit Übungs-ID ohne Asset-Governance** — Seitenkanal-Risiko.
|
||||
2. **Copyright leer bei `official`** — Spec verbietet das fachlich.
|
||||
3. **Physisches Löschen bei Referenzanzahl > 0** — Spec §5.3.
|
||||
4. **Embed-URLs durch Lifecycle-Purge** — Embeds haben anderen Lebenszyklus.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [MEDIA_ASSETS_AND_ARCHIVE_SPEC.md](../../../.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md)
|
||||
- [ACCESS_LAYER_DESIGN_PRINCIPLES.md](./ACCESS_LAYER_DESIGN_PRINCIPLES.md)
|
||||
- [CONTENT_REPORTS_DESIGN_PRINCIPLES.md](./CONTENT_REPORTS_DESIGN_PRINCIPLES.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
# Migration & Deploy – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Migration & Deploy“ — Schema-Evolution, Container-Start
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 13 von 15
|
||||
**Mitai-Vergleich:** [MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md) (#9)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| DB-Init | `backend/db_init.py` |
|
||||
| Migrationen | `backend/migrations/XXX_*.sql` |
|
||||
| Version | `backend/version.py` (`DB_SCHEMA_VERSION`, `MODULE_VERSIONS`) |
|
||||
| Docker | `docker-compose.yml`, `docker-compose.dev-env.yml` |
|
||||
| Deploy | develop → dev.shinkan · main → shinkan (Pi) |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Migration & Deploy**
|
||||
|
||||
Nummerierte SQL-Migrationen beim Container-Start, Tracking in `schema_migrations`, Git-Branch → Umgebung, fail-fast ohne Auto-Rollback.
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Nummerierte Migrationen `XXX_*.sql`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Nur nummerierte Dateien in `backend/migrations/`; lexikographische Reihenfolge. |
|
||||
| **Begründung** | Familien-Standard Mitai/Shinkan; vorhersagbare Anwendung. |
|
||||
| **Quelle** | `db_init.py`; `CLAUDE.md` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Manuelle Nummern-Kollisionen vermeiden — Team-Disziplin. |
|
||||
|
||||
### 2. Startup vor App — Migrationen blockieren Start bei Fehler
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `db_init.py` wartet auf Postgres, wendet fehlende Migrationen an, dann FastAPI. |
|
||||
| **Begründung** | Keine App mit veraltetem Schema. |
|
||||
| **Quelle** | Container-Entrypoint / startup |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Kein automatisches Rollback — manuelle Recovery. |
|
||||
|
||||
### 3. `schema_migrations` Tracking-Tabelle
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Jede angewendete Datei wird persistiert; Wiederholung überspringt Bekannte. |
|
||||
| **Begründung** | Idempotenz über Deploys hinweg. |
|
||||
| **Quelle** | `ensure_migration_table`, `get_applied_migrations` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Geänderte Migration nach Apply — nicht neu ausführen (neue Nummer). |
|
||||
|
||||
### 4. `DB_SCHEMA_VERSION` als dokumentierter Stand
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `version.py` führt Schema-Version; MODULE_VERSIONS für Subsysteme. |
|
||||
| **Begründung** | Support und Handover wissen erwarteten Stand. |
|
||||
| **Quelle** | `backend/version.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Manuell pflegen bei Migration — Drift möglich. |
|
||||
|
||||
### 5. develop/main → Dev/Prod mit festen Ports
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Dev 3098/8098 · Prod 3003/8003 — nie ändern ohne explizite Freigabe. |
|
||||
| **Begründung** | Deploy-Infrastruktur auf Pi/Synology stabil. |
|
||||
| **Quelle** | `CLAUDE.md` Deployment |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 6. Neue Spalten nur via Migration
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Kein ad-hoc ALTER in Routern; Coding Rules. |
|
||||
| **Begründung** | Reproduzierbare Umgebungen. |
|
||||
| **Quelle** | `.claude/rules/CODING_RULES.md` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 7. IF NOT EXISTS / defensive SQL wo sinnvoll
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Migrationen tolerant bei Wiederanlauf in Dev — aber Tracking verhindert Doppel-Apply. |
|
||||
| **Begründung** | Recovery in Entwicklung erleichtern. |
|
||||
| **Quelle** | Mitai-Migrations-Muster |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Nicht alles idempotent — komplexe Migrationen brauchen Transaktion. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Manuelle psql-Schritte in Prod** ohne nummerierte Migration im Repo.
|
||||
2. **Schema-Drift nur in schema.sql** ohne Migration — Init vs. Upgrade verwechseln.
|
||||
3. **Auto-Rollback bei fehlgeschlagener Migration** — fail-fast, manuell fixen.
|
||||
4. **Port-Änderung ohne Infra-Update** — bricht Fritz!Box/NAS-Routing.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [DATABASE_SCHEMA.md](../../../.claude/docs/technical/DATABASE_SCHEMA.md)
|
||||
- Mitai: [MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/MIGRATION_DEPLOY_DESIGN_PRINCIPLES.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
# Navigation / IA – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Navigation & Information Architecture“
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 12 von 15
|
||||
**Mitai-Vergleich:** [NAVIGATION_IA_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/NAVIGATION_IA_DESIGN_PRINCIPLES.md) (#8)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Hauptnav | `frontend/src/config/appNav.js` |
|
||||
| Admin-Nav | `frontend/src/components/AdminPageNav.jsx` |
|
||||
| Shells | `RequireAdmin`, App-Layout in `App.jsx` |
|
||||
| Return-Kontext | `.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md` |
|
||||
| Styles | `frontend/src/app.css` (`.admin-top-nav`, Bottom-Nav) |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Navigation / IA**
|
||||
|
||||
Single Source of Truth für Hauptnavigation (Mobile Bottom + Desktop Sidebar), separater Admin-Hub, Onboarding-Nav ohne Vereinsfeatures, rollen- und kontextabhängige Einblendung (Posteingang, Admin).
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. `appNav.js` als SSoT für Hauptnavigation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `getMainNavItems(isAdmin, opts)` liefert Route, Label, Icon — eine Liste für Mobile und Desktop. |
|
||||
| **Begründung** | Gleiches Familien-Muster wie Mitai `appNav`; keine divergierenden Nav-Arrays. |
|
||||
| **Quelle** | `appNav.js` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Tiefe Unterrouten (Übung bearbeiten) nicht in Top-Nav — Shell/Back. |
|
||||
|
||||
### 2. Admin als separater Hub
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `/admin/*` mit horizontaler `AdminPageNav` — Plattform-Werkzeuge gebündelt. |
|
||||
| **Begründung** | Trainer-Nav bleibt schlank; Admin-IA skaliert unabhängig. |
|
||||
| **Quelle** | `AdminPageNav.jsx` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Admin-Nav hardcoded Array — kein `adminNav.js` SSoT wie Mitai ideal. |
|
||||
|
||||
### 3. Onboarding-Nav reduziert
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `getOnboardingNavItems()` — nur Verein + Einstellungen ohne Übungen/Planung. |
|
||||
| **Begründung** | Nutzer ohne Vereinsmitgliedschaft nicht in leere Bereiche führen. |
|
||||
| **Quelle** | `appNav.js` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 4. Kontextabhängige Items (Posteingang)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `showInbox` Flag steuert Posteingang-Eintrag — Berechtigung aus Entitlements/Rolle. |
|
||||
| **Begründung** | Kein toter Nav-Link für Trainer ohne Inbox-Recht. |
|
||||
| **Quelle** | `baseItems({ showInbox })` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Logik zur `showInbox`-Setzung in App.jsx pflegen. |
|
||||
|
||||
### 5. Responsive: Bottom-Nav Mobile, Sidebar Desktop
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Gleiche Items, unterschiedliche Präsentation; CSS-Variablen für Abstände. |
|
||||
| **Begründung** | PWA-typisches Muster; 80px Bottom-Padding für Nav. |
|
||||
| **Quelle** | `app.css`; Design-System in `CLAUDE.md` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Breakpoint-Konsistenz mit Mitai (1024px) prüfen beim Familien-Review. |
|
||||
|
||||
### 6. Return-Kontext für tiefe Bearbeitung
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Spez `NAV_RETURN_CONTEXT_SPEC` — zurück zur Herkunftsliste mit Filter-State. |
|
||||
| **Begründung** | Übungs-Editor aus Suche/Planung ohne Navigations-Verlust. |
|
||||
| **Quelle** | Return-Context Spec |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Nicht alle Flows implementiert. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Zwei unterschiedliche Nav-Arrays** für Mobile vs. Desktop.
|
||||
2. **Admin-Routen in Haupt-Bottom-Nav** mischen (außer ein Admin-Einstieg).
|
||||
3. **Hardcodierte Nav in jeder Page** — zentral in `appNav.js`.
|
||||
4. **Fehlender Onboarding-Gate** — volle Nav ohne Verein verwirrt.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [NAV_RETURN_CONTEXT_SPEC.md](../../../.claude/docs/technical/NAV_RETURN_CONTEXT_SPEC.md)
|
||||
- Mitai: [NAVIGATION_IA_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/NAVIGATION_IA_DESIGN_PRINCIPLES.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
# Rights Registry – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Rights Registry“ — Capabilities & Features zur Laufzeit registrieren
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 3 von 15
|
||||
**Mitai-Vergleich:** [REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/REGISTRY_PLUGIN_DESIGN_PRINCIPLES.md) (#4)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Registry-Kern | `backend/rights_registry.py` |
|
||||
| Modul-Registrierungen | `backend/rights_registrations/` (`exercises.py`, `planning.py`, `platform.py`, `club_creation.py`) |
|
||||
| Startup-Sync | Import in `backend/main.py` |
|
||||
| Tests | `backend/tests/test_rights_registry.py` |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Rights Registry (Registry-first für Capabilities & Features)**
|
||||
|
||||
Module deklarieren bei Implementierung, welche Rechte und Kontingente sie anbieten. Beim App-Start werden Definitionen in die DB synchronisiert (`capabilities`, `features`, Default-Grants).
|
||||
|
||||
---
|
||||
|
||||
## Fachliche Verantwortung
|
||||
|
||||
1. **Runtime-Registrierung** — `register_capability()`, `register_feature()` vor DB-Sync.
|
||||
2. **Modul-Ownership** — Jedes Feature/Capability trägt `module`-Feld für Admin-Filter „Rollen & Rechte“.
|
||||
3. **Default Club Grants** — Rollen → Capability-Mapping bei Erst-Sync.
|
||||
4. **Kein vollständiger Vorab-Katalog in SQL-Migration** — nur was Module wirklich liefern.
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Registry-first statt Migrations-Monolith
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Neue Rechte erscheinen durch Code-Registrierung + Startup-Upsert — nicht durch manuelle 079-Katalog-Migration pro Feature. |
|
||||
| **Begründung** | Modul und Recht entstehen zusammen; weniger vergessene Katalog-Einträge. |
|
||||
| **Quelle** | `rights_registry.py`; Docstring; `rights_registrations/` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Erste Basismigration seedet noch initiale Zeilen. |
|
||||
|
||||
### 2. Modul-Datei pro Domäne
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `rights_registrations/exercises.py` registriert nur Übungs-Rechte; Planung/Platform analog. |
|
||||
| **Begründung** | Ownership klar; Merge-Konflikte lokalisiert. |
|
||||
| **Quelle** | `rights_registrations/__init__.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Import-Reihenfolge muss in `main.py` garantiert sein. |
|
||||
|
||||
### 3. Frozen Dataclass-Definitionen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `CapabilityRegistration` / `FeatureRegistration` als immutable `@dataclass(frozen=True)`. |
|
||||
| **Begründung** | Keine nachträgliche Mutation nach Registrierung. |
|
||||
| **Quelle** | `rights_registry.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 4. Validierung an der Registrierungsgrenze
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `register_*` wirft bei fehlendem `id` oder `module`. |
|
||||
| **Begründung** | Fehler beim Import/Startup, nicht erst im Admin-UI. |
|
||||
| **Quelle** | `rights_registry.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Keine Schema-Validierung für `limit_type`/`reset_period` zur Compile-Zeit. |
|
||||
|
||||
### 5. DB als persistierter Katalog, Code als SSoT für neue IDs
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Startup `sync_rights_registry_to_db()` upsertet aus In-Memory-Registry. |
|
||||
| **Begründung** | Admin-UI liest DB; Entwickler erweitern Code-Registry. |
|
||||
| **Quelle** | `rights_registry.py`; `main.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Deaktivierte Capabilities in DB vs. fehlende im Code — Reconcile-Policy dokumentieren. |
|
||||
|
||||
### 6. Default Grants als Code-Daten
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `default_club_grants: (role_code, capability_id)` pro Capability. |
|
||||
| **Begründung** | Neue Module bringen sinnvolle Standard-Rollen mit. |
|
||||
| **Quelle** | `rights_registrations/exercises.py` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Admin-Overrides in DB können bei Re-Sync überschrieben werden — ON CONFLICT-Verhalten beachten. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Capabilities nur in SQL-Migration pflegen** — driftet vom implementierten Modul weg.
|
||||
2. **Registrierung ohne Endpoint-Verdrahtung** — Spec: „nur Rechte mit echter Endpoint-Verdrahtung“.
|
||||
3. **Zweite Registry-Philosophie** für Custom Roles — gleiche Capability-IDs wiederverwenden (Plan Stufe E).
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md](./CAPABILITY_ENTITLEMENT_DESIGN_PRINCIPLES.md)
|
||||
- [CAPABILITY_CATALOG.v1.md](../../../.claude/docs/technical/CAPABILITY_CATALOG.v1.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
# Skill Scoring – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Skill Scoring & Profile“ — gewichtete Fähigkeiten-KPIs
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 8 von 15
|
||||
**Mitai-Vergleich:** [DATA_LAYER_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/DATA_LAYER_DESIGN_PRINCIPLES.md) (#2, analog: Berechnungs-SSoT)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Kern | `backend/skill_scoring.py` |
|
||||
| Profile-API | `backend/routers/skill_profiles.py` |
|
||||
| Planungs-Vorschläge | Planung-Router + Fähigkeiten-Seite |
|
||||
| Spec | `.claude/docs/technical/SKILL_SCORING_SPEC.md` |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Skill Scoring & Profiles**
|
||||
|
||||
Regelbasierte Aggregation von `exercise_skills` über Artefakte (Module, Rahmenprogramme, Pläne, Graphen) zu gewichteten Profilen mit Peer-Vergleich innerhalb desselben Artefakttyps.
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Berechnung in einer Schicht (`skill_scoring.py`)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Scores, Gewichte, Peer-Perzentile — nicht in React oder Router-SQL duplizieren. |
|
||||
| **Begründung** | Analog Mitai Data Layer: Charts, Listen-KPIs, Planungs-Vorschläge nutzen dieselbe Logik. |
|
||||
| **Quelle** | `SKILL_SCORING_SPEC.md`; `skill_scoring.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Einzelne UI-Fallbacks können noch vereinfacht rechnen. |
|
||||
|
||||
### 2. Gewichtung aus Trainings-Signalen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Dauer, Vorkommen, Intensität (`niedrig`/`mittel`/`hoch`), Stufen-Spanne — explizite Multiplikatoren. |
|
||||
| **Begründung** | Nachvollziehbares Ranking ohne Black-Box-ML. |
|
||||
| **Quelle** | `_INTENSITY_MULT`, `_level_range_multiplier` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | `is_primary` / `development_contribution` bewusst ignoriert. |
|
||||
|
||||
### 3. Peer-Vergleich nur unter gleichem Artefakttyp
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Modul vs. Modul, Rahmen vs. Rahmen — nie Modul vs. Plan gemischt. |
|
||||
| **Begründung** | Fachlich sinnvoller Vergleich; vermeidet irreführende Prozentwerte. |
|
||||
| **Quelle** | Phase 3 Lieferung; Nutzerfunktionen §4.2 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | UI muss Typ-Kontext klar labeln. |
|
||||
|
||||
### 4. Sichtbarkeits-filterte Peer-Menge
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Peer-Pool = nur für Nutzer sichtbare Artefakte (Access Layer). |
|
||||
| **Begründung** | Keine Leaks über Scores fremder Vereins-Inhalte. |
|
||||
| **Quelle** | `skill_scoring.py` + Tenant-Filter in Aufrufern |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Performance bei großen Pools. |
|
||||
|
||||
### 5. Planungs-Vorschläge aus Profil-Delta
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Fähigkeiten-Schwerpunkte → sortierte Vorschläge für Module/Rahmen/Regressionspfade. |
|
||||
| **Begründung** | Schließt Loop zwischen Katalog und Planung. |
|
||||
| **Quelle** | Fähigkeiten-Seite Phase 3 |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | KI-Suche über Volltext — Backlog. |
|
||||
|
||||
### 6. Default-Minuten für fehlende Dauer
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `DEFAULT_ITEM_MINUTES` / `GRAPH_DEFAULT_ITEM_MINUTES` als explizite Konstanten. |
|
||||
| **Begründung** | Deterministische Scores bei unvollständigen Planungsdaten. |
|
||||
| **Quelle** | `skill_scoring.py` |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Fachlich kalibrierbar — dokumentieren statt verstecken. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Score-Berechnung im Frontend** für Listen-KPIs.
|
||||
2. **Peer-Vergleich über Artefakttypen hinweg** — irreführend.
|
||||
3. **ML-Black-Box statt regelbasierter Gewichte** — ohne explizite Produktentscheidung.
|
||||
4. **Ignorieren der Tenant-Sichtbarkeit** im Peer-Pool.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [SKILL_SCORING_SPEC.md](../../../.claude/docs/technical/SKILL_SCORING_SPEC.md)
|
||||
- [EXERCISE_CATALOG_DESIGN_PRINCIPLES.md](./EXERCISE_CATALOG_DESIGN_PRINCIPLES.md)
|
||||
- [TRAINING_PLANNING_DESIGN_PRINCIPLES.md](./TRAINING_PLANNING_DESIGN_PRINCIPLES.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
# Training Planning – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „Training Planning“ — Einheiten, Phasen, Rahmen, Coach
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 9 von 15
|
||||
**Mitai-Vergleich:** — (domänenspezifisch)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Kalender-Einheiten | `backend/routers/training_planning.py` |
|
||||
| Module | `backend/routers/training_modules.py` |
|
||||
| Rahmen | `backend/routers/training_framework_programs.py` |
|
||||
| Phasen/Streams | Migration 063; `PARALLEL_TRAINING_STREAMS_SPEC.md` |
|
||||
| Frontend | `TrainingPlanningPage`, `TrainingCoachPage`, `TrainingUnitRunPage` |
|
||||
| Utils | `frontend/src/utils/trainingPlanUtils.js` |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Training Planning & Frameworks**
|
||||
|
||||
Planbare Trainingseinheiten mit Sektionen, **Phasen** (Ganzgruppe/Parallel) und **Streams**, Bibliotheks-**Rahmenprogramme** (Ziele/Slots), **Trainingsmodule**, Materialisierung aus Slots, Durchführungs- und Coaching-Ansichten.
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Einheit als planbares Aggregate
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `training_units` + `training_unit_sections` + Items; Kopf: Gruppe, Datum, Trainer, Status. |
|
||||
| **Begründung** | Klare Grenze Kalender vs. Bibliothek. |
|
||||
| **Quelle** | Domain Model; `training_planning.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Große Router-Datei — Refaktor-Schuld. |
|
||||
|
||||
### 2. Phasen/Streams als explizites Modell (nicht Marker-Sektionen)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `training_unit_phases` + `training_unit_parallel_streams`; Sektionen an Phase oder Stream gebunden. |
|
||||
| **Begründung** | Breakout-Trainings fachlich korrekt; Coach/Rejoin-Logik. |
|
||||
| **Quelle** | `PARALLEL_TRAINING_STREAMS_SPEC.md` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Legacy-Einheiten → Default-Ganzgruppenphase; Vorlagen-Phasen teils offen. |
|
||||
|
||||
### 3. API: verschachtelte `phases` + flache `sections`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | GET liefert beides; PUT akzeptiert `phases` atomar; höchstens eines von phases/sections/exercises pro Request. |
|
||||
| **Begründung** | Frontend normalisiert; Server validiert CHECK-Regeln. |
|
||||
| **Quelle** | Spec §4; Planning-Router |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Server-Spiegelung neuer Abschnitte in phases — Handover offen. |
|
||||
|
||||
### 4. Rahmen-Bibliothek: Slot = Blueprint-Unit
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `framework_slot_id` auf Blueprint-`training_units`; Materialisierung → Kalender-Einheit für Gruppe. |
|
||||
| **Begründung** | Wiederverwendbare Programme ohne Duplikat-Logik pro Slot-Typ. |
|
||||
| **Quelle** | Migration 035–037 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | UI „Aus Rahmen übernehmen“ nicht flächendeckend. |
|
||||
|
||||
### 5. Trainingsmodule als wiederverwendbare Übungsfolgen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Bibliotheks-Objekt mit Skill-Profil; Übernahme in geplante Einheit. |
|
||||
| **Begründung** | Trainer-Bausteine zwischen Einzelübung und Rahmen. |
|
||||
| **Quelle** | `training_modules.py`; Skill Scoring Phase 3 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 6. Drei Durchführungsmodi getrennt
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Planung (edit) · Plan & Ablauf (run) · Coaching (step timeline, Stream-Picks, Nachbereitung). |
|
||||
| **Begründung** | Unterschiedliche UX und Payloads; Coach speichert → Run-Ansicht. |
|
||||
| **Quelle** | Nutzerfunktionen §4.4; `TrainingCoachPage` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Stream-Tabs in Run-Ansicht optional offen. |
|
||||
|
||||
### 7. Governance über Gruppe/Verein, keine neuen Mandanten-Entitäten
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Einheit → `training_group` → Verein; Access Layer für Bibliotheks-Rahmen/Module. |
|
||||
| **Begründung** | Planung erbt Organisations-Kontext. |
|
||||
| **Quelle** | `PARALLEL_TRAINING_STREAMS_SPEC.md` §4 |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Stream-Trainer-Zuweisung UI unvollständig. |
|
||||
|
||||
### 8. Kombinationsübungen in Planung transparent
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | Items ohne Variante; Coach zeigt Stations-Kandidaten + Archetyp-Hinweise. |
|
||||
| **Begründung** | Gleiche Item-Schicht für Standard- und Kombi-Übungen. |
|
||||
| **Quelle** | Migration 057; Kombinations-Spec Anhang A |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Archetyp-Stufen B/C ausbaubar. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Parallele Phasen als reine UI-Konvention ohne DB-Phasen** — Minimalvariante verworfen.
|
||||
2. **Rahmen-Slots als separate Exercise-Join-Tabelle** — Legacy `training_framework_slot_exercises` abgelöst.
|
||||
3. **Planungs-KI direkt in Router-Strings** — AI Prompt Runtime nutzen.
|
||||
4. **Blueprint-Einheiten in Kalenderlisten** — Filter `framework_slot_id IS NOT NULL`.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [PARALLEL_TRAINING_STREAMS_SPEC.md](../../../.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md)
|
||||
- [TRAINING_FRAMEWORK_SPEC.md](../../../.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md)
|
||||
- [SKILL_SCORING_DESIGN_PRINCIPLES.md](./SKILL_SCORING_DESIGN_PRINCIPLES.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
# Wiki Import – Designprinzipien (Extraktion)
|
||||
|
||||
**Status:** Analyse / Arbeitspapier
|
||||
**Stand:** 2026-07-04
|
||||
**Geltungsbereich:** Modul „MediaWiki Import“ — SMW-Ingest, Mapping, Tracking
|
||||
|
||||
**Serie:** Designprinzipien für Produktfamilie · Shinkan Dokument 11 von 15
|
||||
**Mitai-Vergleich:** [UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md) (#6)
|
||||
|
||||
**Kernkomponenten:**
|
||||
|
||||
| Bereich | Pfade |
|
||||
|---------|-------|
|
||||
| Router | `backend/routers/import_wiki.py`, `import_wiki_admin.py` |
|
||||
| Client | `backend/smw_client.py` |
|
||||
| Mapper | `backend/smw_mapper.py` |
|
||||
| Tracking | `wiki_import_log`, `wiki_import_references` |
|
||||
| Spec | `.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md` |
|
||||
|
||||
---
|
||||
|
||||
## Modul
|
||||
|
||||
**Wiki Import (Semantic MediaWiki)**
|
||||
|
||||
Import von Übungen, Fähigkeiten, Methoden und Reifegradmodellen aus externem Wiki via API — Preview, Dry-Run, Duplikat-Erkennung, Admin-only.
|
||||
|
||||
---
|
||||
|
||||
## Designprinzipien
|
||||
|
||||
### 1. Ingest ≠ Interpretation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `SmwClient` holt Rohdaten; `smw_mapper` mappt auf Shinkan-Modelle — getrennte Schichten. |
|
||||
| **Begründung** | Analog Mitai Import: Transport/Parser ≠ Domänen-Insert. |
|
||||
| **Quelle** | `import_wiki.py`; Mitai Universal Import |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Keine generische Import-Registry wie Mitai CSV — wiki-spezifisch. |
|
||||
|
||||
### 2. Preview und Dry-Run vor Execute
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `/preview` zeigt Kandidaten; `dry_run=true` ohne DB-Schreiben. |
|
||||
| **Begründung** | Admin sieht Auswirkungen; sichere Iteration. |
|
||||
| **Quelle** | `ImportExecuteRequest` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 3. Duplikat-Tracking über Wiki-Referenzen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `wiki_import_references` speichert Wiki-Titel ↔ Shinkan-ID für Re-Import. |
|
||||
| **Begründung** | Idempotenz und Update statt blindem Duplicate. |
|
||||
| **Quelle** | Domain Model Import-Tabellen |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Gap-Analyse in `SMW_IMPORTER_GAP_ANALYSIS.md` beachten. |
|
||||
|
||||
### 4. Import-Typ als expliziter Parameter
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `import_type`: `exercise` \| `skill` \| `method` \| Modelle — eigener Mapper-Pfad. |
|
||||
| **Begründung** | Klare Verantwortung pro Ziel-Entität. |
|
||||
| **Quelle** | `map_wiki_to_*` in `smw_mapper.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | Kein Plug-in-Registry-Pattern wie Mitai Module-Registry. |
|
||||
|
||||
### 5. Superadmin/Admin-only Execute
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `require_admin` auf Execute — Massenimport ist Plattform-Risiko. |
|
||||
| **Begründung** | Governance und Datenqualität. |
|
||||
| **Quelle** | `import_wiki.py` |
|
||||
| **Tragfähigkeit** | **hoch** |
|
||||
| **Einschränkung** | — |
|
||||
|
||||
### 6. Kategorie aus Env mit Fallback
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | `MEDIAWIKI_CATEGORY_*` Env-Variablen; leere Query → Default je Typ. |
|
||||
| **Begründung** | Wiki-Struktur konfigurierbar ohne Code-Deploy. |
|
||||
| **Quelle** | `CATEGORY_EXERCISES` etc. |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Hardcoded Wiki-URL in Doku — Umgebungsspezifisch halten. |
|
||||
|
||||
### 7. Background Tasks für lange Imports
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Prinzip** | FastAPI `BackgroundTasks` für Execute — HTTP nicht blockieren. |
|
||||
| **Begründung** | Große Kategorien ohne Timeout. |
|
||||
| **Quelle** | Execute-Endpoint |
|
||||
| **Tragfähigkeit** | **mittel** |
|
||||
| **Einschränkung** | Kein Job-Status-Polling-UI wie Mitai — Log-Tabelle nutzen. |
|
||||
|
||||
---
|
||||
|
||||
## Nicht übernehmen
|
||||
|
||||
1. **Rohe Wiki-HTML ungemappt in DB** — immer Mapper.
|
||||
2. **Import ohne Log/Re-Import-Referenz** — Duplikat-Chaos.
|
||||
3. **Trainer-self-service Wiki-Import** — Admin-only.
|
||||
4. **Skill-Scoring beim Insert** — Scores gehören in `skill_scoring`-Schicht.
|
||||
|
||||
---
|
||||
|
||||
## Verwandte Dokumentation
|
||||
|
||||
- [MEDIAWIKI_IMPORT_SPEC.md](../../../.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md)
|
||||
- [EXERCISE_CATALOG_DESIGN_PRINCIPLES.md](./EXERCISE_CATALOG_DESIGN_PRINCIPLES.md)
|
||||
- Mitai: [UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md](file:///c:/Dev/mitai-jinkendo/.claude/docs/technical/UNIVERSAL_IMPORT_DESIGN_PRINCIPLES.md)
|
||||
- [DESIGN_PRINCIPLES_INDEX.md](./DESIGN_PRINCIPLES_INDEX.md)
|
||||
|
|
@ -11,9 +11,11 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"dompurify": "^3.4.17",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^0.344.0",
|
||||
"marked": "^18.0.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
|
@ -22,6 +24,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"vite": "^5.1.4",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import AdminHomeRedirect from './components/AdminHomeRedirect'
|
|||
import PlatformAdminRoute from './components/PlatformAdminRoute'
|
||||
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
|
||||
import InactiveMembershipBanner from './components/InactiveMembershipBanner'
|
||||
import TotpSetupCard from './components/TotpSetupCard'
|
||||
import './app.css'
|
||||
|
||||
const OnboardingPage = lazyWithRetry(() => import('./pages/OnboardingPage'))
|
||||
|
|
@ -126,6 +127,29 @@ function Nav({ showAdminNav, onboardingOnly }) {
|
|||
)
|
||||
}
|
||||
|
||||
function TotpEnrollGate() {
|
||||
const { checkAuth, logout } = useAuth()
|
||||
return (
|
||||
<div className="page-padding" style={{ maxWidth: 520, margin: '2rem auto' }}>
|
||||
<h1 style={{ fontSize: '1.4rem' }}>Zwei-Faktor einrichten</h1>
|
||||
<p style={{ color: 'var(--text2)' }}>
|
||||
Plattform-Admins melden sich erst an, wenn ein Authenticator eingerichtet ist.
|
||||
</p>
|
||||
<TotpSetupCard onEnabled={() => checkAuth()} />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={async () => {
|
||||
await logout()
|
||||
window.location.href = '/login'
|
||||
}}
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProtectedLayout() {
|
||||
const { isAuthenticated, loading, user, logout } = useAuth()
|
||||
|
||||
|
|
@ -155,6 +179,10 @@ function ProtectedLayout() {
|
|||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (user?.totpEnrollmentRequired) {
|
||||
return <TotpEnrollGate />
|
||||
}
|
||||
|
||||
const location = useLocation()
|
||||
const onboardingOnly = isOnboardingRestricted(user)
|
||||
if (onboardingOnly && !isOnboardingAllowedPath(location.pathname)) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import ExerciseMediaEmbed from './ExerciseMediaEmbed'
|
|||
import ExerciseMediaThumbTile from './ExerciseMediaThumbTile'
|
||||
import MediaPreviewModal from './MediaPreviewModal'
|
||||
import ReportContentModal from './ReportContentModal'
|
||||
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
|
||||
import { resolveExerciseMediaFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl'
|
||||
import { collectInlineExerciseMediaIdsFromExercise } from '../utils/exerciseInlineMediaRefs'
|
||||
|
||||
function isTrashHidden(m) {
|
||||
|
|
@ -14,6 +14,7 @@ function isTrashHidden(m) {
|
|||
}
|
||||
|
||||
export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) {
|
||||
const mediaToken = useMediaAccessToken()
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [reportTarget, setReportTarget] = useState(null)
|
||||
const inlineIds = useMemo(() => collectInlineExerciseMediaIdsFromExercise(exercise), [exercise])
|
||||
|
|
@ -66,7 +67,7 @@ export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) {
|
|||
<MediaPreviewModal
|
||||
title={(preview.title || '').trim() || preview.original_filename || `Medium #${preview.id}`}
|
||||
media={preview}
|
||||
fileUrl={preview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, preview)}
|
||||
fileUrl={preview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, preview, mediaToken)}
|
||||
onClose={() => setPreview(null)}
|
||||
onReport={
|
||||
(preview.asset_lifecycle_state || 'active') === 'active' && !preview.asset_legal_hold_active
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import api from '../utils/api'
|
||||
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
|
||||
import { resolveMediaAssetFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl'
|
||||
import {
|
||||
INLINE_MEDIA_SIZES,
|
||||
DEFAULT_INLINE_MEDIA_SIZE,
|
||||
|
|
@ -12,8 +12,9 @@ import {
|
|||
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
|
||||
|
||||
function RtePickerAssetThumb({ asset }) {
|
||||
const mediaToken = useMediaAccessToken()
|
||||
const id = asset.id
|
||||
const src = resolveMediaAssetFileUrl(id)
|
||||
const src = resolveMediaAssetFileUrl(id, mediaToken)
|
||||
const mt = (asset.mime_type || '').toLowerCase()
|
||||
if (mt.startsWith('image/') && src) {
|
||||
return <img alt="" src={src} className="rte-inline-asset-tile__thumb-img" />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
|
||||
import { resolveExerciseMediaFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl'
|
||||
import { sanitizeInlineMediaSize } from '../constants/inlineExerciseMedia'
|
||||
|
||||
/**
|
||||
|
|
@ -7,6 +7,7 @@ import { sanitizeInlineMediaSize } from '../constants/inlineExerciseMedia'
|
|||
* @param {{ media: object, exerciseId: number, layoutSize?: string }} props
|
||||
*/
|
||||
export default function ExerciseMediaEmbed({ exerciseId, media, layoutSize = 'medium' }) {
|
||||
const mediaToken = useMediaAccessToken()
|
||||
const sz = sanitizeInlineMediaSize(layoutSize)
|
||||
const box =
|
||||
sz === 'small'
|
||||
|
|
@ -46,7 +47,7 @@ export default function ExerciseMediaEmbed({ exerciseId, media, layoutSize = 'me
|
|||
</div>
|
||||
)
|
||||
}
|
||||
const src = resolveExerciseMediaFileUrl(exerciseId, media)
|
||||
const src = resolveExerciseMediaFileUrl(exerciseId, media, mediaToken)
|
||||
if (!src) return null
|
||||
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
* Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img, Embed = Label.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
|
||||
import { resolveExerciseMediaFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl'
|
||||
|
||||
export default function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview, size = 72 }) {
|
||||
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media) : null
|
||||
const mediaToken = useMediaAccessToken()
|
||||
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media, mediaToken) : null
|
||||
const commonStyle = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
|
|
|||
163
frontend/src/components/TotpSetupCard.jsx
Normal file
163
frontend/src/components/TotpSetupCard.jsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import api from '../utils/api'
|
||||
|
||||
/**
|
||||
* TOTP einrichten: QR-Code und Geheimnis anzeigen, Code bestätigen, Wiederherstellungscodes einmal zeigen.
|
||||
*/
|
||||
export default function TotpSetupCard({ onEnabled }) {
|
||||
const [secret, setSecret] = useState('')
|
||||
const [otpauthUri, setOtpauthUri] = useState('')
|
||||
const [qrDataUrl, setQrDataUrl] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [recoveryCodes, setRecoveryCodes] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!otpauthUri) {
|
||||
setQrDataUrl('')
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
import('qrcode')
|
||||
.then((mod) => {
|
||||
const QRCode = typeof mod.toDataURL === 'function' ? mod : mod.default
|
||||
return QRCode.toDataURL(otpauthUri, {
|
||||
width: 240,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
})
|
||||
})
|
||||
.then((url) => {
|
||||
if (!cancelled) setQrDataUrl(url)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setQrDataUrl('')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [otpauthUri])
|
||||
|
||||
const start = async () => {
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const data = await api.setupTotp()
|
||||
setSecret(data.secret || '')
|
||||
setOtpauthUri(data.otpauth_uri || '')
|
||||
setRecoveryCodes([])
|
||||
} catch (err) {
|
||||
setError(err.message || 'Einrichtung fehlgeschlagen')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirm = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const data = await api.confirmTotp(code.trim())
|
||||
if (data?.token) localStorage.setItem('authToken', data.token)
|
||||
setRecoveryCodes(Array.isArray(data?.recovery_codes) ? data.recovery_codes : [])
|
||||
setSecret('')
|
||||
setOtpauthUri('')
|
||||
setCode('')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Code nicht akzeptiert')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Zwei-Faktor-Anmeldung</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', lineHeight: 1.5 }}>
|
||||
Authenticator-App öffnen, ein Konto hinzufügen und den QR-Code scannen. Wenn das Scannen
|
||||
nicht klappt, das Geheimnis darunter manuell eintragen.
|
||||
</p>
|
||||
{error ? (
|
||||
<p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>
|
||||
) : null}
|
||||
{!secret && recoveryCodes.length === 0 ? (
|
||||
<button type="button" className="btn btn-primary" onClick={start} disabled={busy}>
|
||||
{busy ? 'Wird erzeugt…' : 'QR-Code erzeugen'}
|
||||
</button>
|
||||
) : null}
|
||||
{secret ? (
|
||||
<form onSubmit={confirm}>
|
||||
{qrDataUrl ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background: '#ffffff',
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
margin: '0.25rem 0 0.85rem',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="QR-Code zum Einrichten in der Authenticator-App"
|
||||
width={240}
|
||||
height={240}
|
||||
style={{ display: 'block', width: 240, height: 240 }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<p style={{ fontFamily: 'ui-monospace, monospace', letterSpacing: '0.04em', wordBreak: 'break-all' }}>{secret}</p>
|
||||
<label className="form-label" htmlFor="totp-confirm-code">
|
||||
Code aus der App
|
||||
</label>
|
||||
<input
|
||||
id="totp-confirm-code"
|
||||
className="form-input"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy} style={{ marginTop: '0.75rem' }}>
|
||||
{busy ? 'Prüft…' : 'Aktivieren'}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
{recoveryCodes.length > 0 ? (
|
||||
<div>
|
||||
<p style={{ fontSize: '0.875rem' }}>
|
||||
Diese acht Codes jetzt sichern. Die Seite bleibt stehen, bis du unten bestätigst.
|
||||
Ein Neuladen löscht die Anzeige. Neue Codes kannst du danach in den Einstellungen erzeugen.
|
||||
</p>
|
||||
<textarea
|
||||
readOnly
|
||||
rows={8}
|
||||
className="form-input"
|
||||
value={recoveryCodes.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(recoveryCodes.join('\n'))}
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
if (onEnabled) onEnabled()
|
||||
}}
|
||||
>
|
||||
Ich habe die Codes gespeichert
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'
|
||||
import { useNavigate, useParams, Link, useLocation } from 'react-router-dom'
|
||||
import api, { buildExerciseApiPayload } from '../../utils/api'
|
||||
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../../utils/exerciseMediaUrl'
|
||||
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl, useMediaAccessToken } from '../../utils/exerciseMediaUrl'
|
||||
import RichTextEditor from '../RichTextEditor'
|
||||
import ExerciseProgressionGraphPanel from '../ExerciseProgressionGraphPanel'
|
||||
import ExerciseMediaThumbTile from '../ExerciseMediaThumbTile'
|
||||
|
|
@ -573,6 +573,7 @@ function detailToForm(exercise) {
|
|||
}
|
||||
|
||||
function ExerciseFormPageRoot() {
|
||||
const mediaToken = useMediaAccessToken()
|
||||
const { id: routeId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
|
@ -2832,7 +2833,7 @@ function ExerciseFormPageRoot() {
|
|||
{a.mime_type?.startsWith('image/') ? (
|
||||
<img
|
||||
alt=""
|
||||
src={resolveMediaAssetFileUrl(a.id)}
|
||||
src={resolveMediaAssetFileUrl(a.id, mediaToken)}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : a.mime_type?.startsWith('video/') ? (
|
||||
|
|
@ -2880,7 +2881,7 @@ function ExerciseFormPageRoot() {
|
|||
<MediaPreviewModal
|
||||
title={(mediaPreview.title || '').trim() || mediaPreview.original_filename || `Medium #${mediaPreview.id}`}
|
||||
media={mediaPreview}
|
||||
fileUrl={mediaPreview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
|
||||
fileUrl={mediaPreview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, mediaPreview, mediaToken)}
|
||||
onClose={() => setMediaPreview(null)}
|
||||
onReport={
|
||||
!mediaPreview.asset_legal_hold_active
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import api, { ACTIVE_CLUB_STORAGE_KEY } from '../utils/api'
|
||||
import { activeClubMemberships } from '../utils/activeClub'
|
||||
import { clearCoachSessionStorage } from '../utils/trainingPlanUtils'
|
||||
import { clearMediaGrant, refreshMediaGrant } from '../utils/exerciseMediaUrl'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
|
|
@ -63,9 +64,25 @@ export function AuthProvider({ children }) {
|
|||
const profile = await api.getCurrentProfile()
|
||||
syncStoredActiveClub(profile)
|
||||
setUser(profile)
|
||||
refreshMediaGrant().catch(() => {})
|
||||
} catch (err) {
|
||||
try {
|
||||
const status = await api.getTotpStatus()
|
||||
if (status?.enrollment_required && !status?.totp_enabled) {
|
||||
setUser({
|
||||
totpEnrollmentRequired: true,
|
||||
email: status.email || null,
|
||||
role: status.role || null,
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
/* kein Einrichtungs-Token */
|
||||
}
|
||||
console.error('Auth check failed:', err)
|
||||
localStorage.removeItem('authToken')
|
||||
clearMediaGrant()
|
||||
setUser(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -132,6 +149,7 @@ export function AuthProvider({ children }) {
|
|||
setUser(null)
|
||||
localStorage.removeItem('authToken')
|
||||
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
|
||||
clearMediaGrant()
|
||||
clearCoachSessionStorage()
|
||||
}, [])
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
resolvePlanningModuleDisplayMode,
|
||||
} from '../config/planningModuleUx'
|
||||
import api from '../utils/api'
|
||||
import TotpSetupCard from '../components/TotpSetupCard'
|
||||
|
||||
/**
|
||||
* Persönliche Einstellungen (Anzeige/Name, Kontostatus, Passwort).
|
||||
|
|
@ -27,6 +28,11 @@ function AccountSettingsPage() {
|
|||
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)
|
||||
|
|
@ -144,8 +150,8 @@ function AccountSettingsPage() {
|
|||
|
||||
const handleChangePassword = async (e) => {
|
||||
e.preventDefault()
|
||||
if (newPw1.length < 4) {
|
||||
showErr('Neues Passwort: mindestens 4 Zeichen.')
|
||||
if (newPw1.length < 8) {
|
||||
showErr('Neues Passwort: mindestens 8 Zeichen.')
|
||||
return
|
||||
}
|
||||
if (newPw1 !== newPw2) {
|
||||
|
|
@ -154,10 +160,12 @@ function AccountSettingsPage() {
|
|||
}
|
||||
setSavingPw(true)
|
||||
try {
|
||||
await api.changePassword(newPw1)
|
||||
const result = await api.changePassword(currentPw, newPw1)
|
||||
if (result?.token) localStorage.setItem('authToken', result.token)
|
||||
setCurrentPw('')
|
||||
setNewPw1('')
|
||||
setNewPw2('')
|
||||
showOk('Passwort aktualisiert.')
|
||||
showOk('Passwort aktualisiert. Andere Geräte sind abgemeldet.')
|
||||
} catch (err) {
|
||||
showErr(err.message || 'Passwort konnte nicht geändert werden.')
|
||||
} finally {
|
||||
|
|
@ -201,6 +209,90 @@ function AccountSettingsPage() {
|
|||
</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 }}>
|
||||
|
|
@ -464,9 +556,23 @@ function AccountSettingsPage() {
|
|||
<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).
|
||||
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
|
||||
|
|
|
|||
|
|
@ -156,6 +156,24 @@ export default function AdminUsersPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const clearUserTotp = async (row) => {
|
||||
const label = row.name || row.email || `#${row.id}`
|
||||
if (
|
||||
!confirm(
|
||||
`Zwei-Faktor für ${label} aufheben? Danach genügt das Passwort. Authenticator und Wiederherstellungscodes gelten nicht mehr. Offene Sitzungen werden beendet.`
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.resetUserTotp(row.id)
|
||||
await loadPlatform()
|
||||
alert('Zwei-Faktor ist aus. Die Person meldet sich mit dem Passwort an und kann ihn neu einrichten.')
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const submitPasswordDirect = async () => {
|
||||
if (!pwdModal) return
|
||||
if (pwdNew.length < 8) {
|
||||
|
|
@ -240,6 +258,8 @@ export default function AdminUsersPage() {
|
|||
<div style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>{row.email || '—'}</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
|
||||
Verifiziert: {row.email_verified ? 'ja' : 'nein'}
|
||||
{' · '}
|
||||
Zwei-Faktor: {row.totp_enabled ? 'aktiv' : 'aus'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'flex-end' }}>
|
||||
|
|
@ -279,6 +299,17 @@ export default function AdminUsersPage() {
|
|||
>
|
||||
Passwort / Link
|
||||
</button>
|
||||
{row.totp_enabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={row.id === user?.id}
|
||||
title={row.id === user?.id ? 'Eigenen Zwei-Faktor unter Einstellungen ändern' : undefined}
|
||||
onClick={() => clearUserTotp(row)}
|
||||
>
|
||||
Zwei-Faktor aufheben
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="btn btn-secondary" onClick={() => savePortal(row.id)}>
|
||||
Portal speichern
|
||||
</button>
|
||||
|
|
@ -573,7 +604,7 @@ export default function AdminUsersPage() {
|
|||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
|
||||
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.75rem' }}>
|
||||
Standard: Es wird ein sicherer Link per E-Mail verschickt (wie „Passwort vergessen“). Das bisherige Passwort
|
||||
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt.
|
||||
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt. Zwei-Faktor bleibt dabei aktiv.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<button type="button" className="btn btn-primary" onClick={submitPasswordEmail}>
|
||||
|
|
|
|||
|
|
@ -1571,7 +1571,7 @@ function ClubsPage() {
|
|||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{pwdModal.label}</p>
|
||||
<p className="muted" style={{ fontSize: '0.82rem', marginBottom: '0.75rem' }}>
|
||||
Standard: Es wird ein sicherer Link per E-Mail verschickt (wie „Passwort vergessen“). Das bisherige Passwort
|
||||
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt.
|
||||
bleibt gültig, bis die Person den Link nutzt und ein neues Passwort wählt. Zwei-Faktor bleibt dabei aktiv.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
<button type="button" className="btn btn-primary" onClick={submitMembersPasswordEmail}>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ function LoginPage() {
|
|||
const [publicClubs, setPublicClubs] = useState([])
|
||||
const [requestedClubId, setRequestedClubId] = useState('')
|
||||
const [resending, setResending] = useState(false)
|
||||
const [challengeToken, setChallengeToken] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { checkAuth } = useAuth()
|
||||
|
|
@ -39,6 +41,17 @@ function LoginPage() {
|
|||
try {
|
||||
if (mode === 'login') {
|
||||
const response = await api.login(email, password)
|
||||
if (response?.totp_required && response.challenge_token) {
|
||||
setChallengeToken(response.challenge_token)
|
||||
setTotpCode('')
|
||||
return
|
||||
}
|
||||
if (response?.totp_enrollment_required && response.token) {
|
||||
localStorage.setItem('authToken', response.token)
|
||||
await checkAuth()
|
||||
navigate('/')
|
||||
return
|
||||
}
|
||||
localStorage.setItem('authToken', response.token)
|
||||
await checkAuth()
|
||||
navigate('/')
|
||||
|
|
@ -60,6 +73,23 @@ function LoginPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleTotp = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await api.loginTotp(challengeToken, totpCode.trim())
|
||||
localStorage.setItem('authToken', response.token)
|
||||
setChallengeToken('')
|
||||
await checkAuth()
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Code nicht akzeptiert')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
if (!email.trim()) {
|
||||
setError('Zuerst die E-Mail-Adresse eintragen.')
|
||||
|
|
@ -122,6 +152,49 @@ function LoginPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{challengeToken ? (
|
||||
<form onSubmit={handleTotp}>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', lineHeight: 1.45 }}>
|
||||
Code aus der Authenticator-App oder ein Wiederherstellungscode.
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="login-totp">
|
||||
Einmalcode
|
||||
</label>
|
||||
<input
|
||||
id="login-totp"
|
||||
className="form-input"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<div style={{ padding: '0.75rem', background: 'var(--danger)', color: 'white', borderRadius: '8px', marginBottom: '1rem' }}>
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary btn-full" disabled={loading}>
|
||||
{loading ? 'Laden...' : 'Bestätigen'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-full"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
onClick={() => {
|
||||
setChallengeToken('')
|
||||
setTotpCode('')
|
||||
setError('')
|
||||
}}
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{!challengeToken ? (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
|
|
@ -215,8 +288,9 @@ function LoginPage() {
|
|||
{loading ? 'Laden...' : mode === 'login' ? 'Anmelden' : 'Registrieren'}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{mode === 'login' && (
|
||||
{mode === 'login' && !challengeToken && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '1.25rem',
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import {
|
|||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub'
|
||||
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
|
||||
import { resolveMediaAssetFileUrl, useMediaAccessToken } from '../utils/exerciseMediaUrl'
|
||||
import RightsDeclarationDialog from '../components/RightsDeclarationDialog'
|
||||
import ReportContentModal from '../components/ReportContentModal'
|
||||
import MediaPreviewModal from '../components/MediaPreviewModal'
|
||||
|
|
@ -159,7 +159,8 @@ function uploaderLabel(it, viewer) {
|
|||
}
|
||||
|
||||
function MediaThumb({ mediaId, mimeType }) {
|
||||
const url = resolveMediaAssetFileUrl(mediaId)
|
||||
const mediaToken = useMediaAccessToken()
|
||||
const url = resolveMediaAssetFileUrl(mediaId, mediaToken)
|
||||
const mime = (mimeType || '').toLowerCase()
|
||||
/* iPhone-Fotos: Browser-Vorschau oft nicht nutzbar */
|
||||
if (mime.includes('heic') || mime.includes('heif')) {
|
||||
|
|
@ -296,6 +297,7 @@ function JournalBoolField({ label, val, context }) {
|
|||
}
|
||||
|
||||
export default function MediaLibraryPage() {
|
||||
const mediaToken = useMediaAccessToken()
|
||||
const { user } = useAuth()
|
||||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
|
|
@ -1122,7 +1124,7 @@ export default function MediaLibraryPage() {
|
|||
<MediaPreviewModal
|
||||
title={preview.original_filename || `Medium #${preview.id}`}
|
||||
media={{ ...preview, asset_legal_hold_active: preview.legal_hold_active }}
|
||||
fileUrl={resolveMediaAssetFileUrl(preview.id)}
|
||||
fileUrl={resolveMediaAssetFileUrl(preview.id, mediaToken)}
|
||||
onClose={() => setPreview(null)}
|
||||
onReport={
|
||||
(preview.lifecycle_state || 'active') === 'active' && !preview.legal_hold_active
|
||||
|
|
|
|||
|
|
@ -25,6 +25,46 @@ export async function login(email, password) {
|
|||
})
|
||||
}
|
||||
|
||||
export async function loginTotp(challengeToken, code) {
|
||||
return request('/api/auth/login/totp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ challenge_token: challengeToken, code }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getTotpStatus() {
|
||||
return request('/api/auth/totp/status')
|
||||
}
|
||||
|
||||
export async function setupTotp() {
|
||||
return request('/api/auth/totp/setup', { method: 'POST', body: '{}' })
|
||||
}
|
||||
|
||||
export async function confirmTotp(code) {
|
||||
return request('/api/auth/totp/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function regenerateRecoveryCodes(currentPassword, code) {
|
||||
return request('/api/auth/totp/recovery-codes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ current_password: currentPassword, code }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function disableTotp(currentPassword, code) {
|
||||
return request('/api/auth/totp/disable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ current_password: currentPassword, code }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function issueMediaGrant() {
|
||||
return request('/api/auth/media-grant', { method: 'POST', body: '{}' })
|
||||
}
|
||||
|
||||
export async function register(email, password, name, extra = {}) {
|
||||
return request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
|
|
@ -111,6 +151,11 @@ export async function updateProfile(profileId, data) {
|
|||
})
|
||||
}
|
||||
|
||||
/** Superadmin: Zwei-Faktor eines anderen Kontos abschalten. */
|
||||
export async function resetUserTotp(profileId) {
|
||||
return request(`/api/profiles/${profileId}/totp-reset`, { method: 'POST', body: '{}' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort anderer Konten: Standard leerer Body → E-Mail mit Reset-Link (wie Passwort vergessen).
|
||||
* Nur Super-Admins dürfen `newPassword` setzen (direktes Überschreiben des Passwort-Hashes).
|
||||
|
|
@ -126,10 +171,13 @@ export async function managementPasswordReset(profileId, newPassword = null) {
|
|||
})
|
||||
}
|
||||
|
||||
export async function changePassword(newPassword) {
|
||||
export async function changePassword(currentPassword, newPassword) {
|
||||
return request('/api/auth/pin', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ pin: newPassword }),
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -992,6 +1040,13 @@ export async function healthCheck() {
|
|||
export const api = {
|
||||
// Auth
|
||||
login,
|
||||
loginTotp,
|
||||
getTotpStatus,
|
||||
setupTotp,
|
||||
confirmTotp,
|
||||
regenerateRecoveryCodes,
|
||||
disableTotp,
|
||||
issueMediaGrant,
|
||||
register,
|
||||
logout,
|
||||
getCurrentProfile,
|
||||
|
|
@ -1005,6 +1060,7 @@ export const api = {
|
|||
deleteAdminUserContentItem,
|
||||
updateProfile,
|
||||
managementPasswordReset,
|
||||
resetUserTotp,
|
||||
changePassword,
|
||||
verifyEmail,
|
||||
resendVerification,
|
||||
|
|
|
|||
|
|
@ -1,31 +1,77 @@
|
|||
/** URL für Übungs-Mediendateien: API mit Token (Legacy /media/ ohne Auth ist abgeschaltet). */
|
||||
/** URL für Übungs-Mediendateien. Query-Token ist ein kurzlebiges Medien-Grant, kein Sitzungstoken. */
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import api from './api'
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
|
||||
|
||||
let grant = { token: '', expiresAt: 0 }
|
||||
const listeners = new Set()
|
||||
|
||||
function notify() {
|
||||
listeners.forEach((fn) => fn())
|
||||
}
|
||||
|
||||
export function peekMediaAccessToken() {
|
||||
if (grant.expiresAt > Date.now() + 15_000) return grant.token
|
||||
return ''
|
||||
}
|
||||
|
||||
export function clearMediaGrant() {
|
||||
grant = { token: '', expiresAt: 0 }
|
||||
notify()
|
||||
}
|
||||
|
||||
export async function refreshMediaGrant() {
|
||||
const data = await api.issueMediaGrant()
|
||||
const expiresAt = data?.expires_at ? new Date(data.expires_at).getTime() : Date.now() + 9 * 60 * 1000
|
||||
grant = { token: data?.token || '', expiresAt }
|
||||
notify()
|
||||
return grant.token
|
||||
}
|
||||
|
||||
export function useMediaAccessToken() {
|
||||
const [token, setToken] = useState(peekMediaAccessToken)
|
||||
useEffect(() => {
|
||||
const pull = () => setToken(peekMediaAccessToken())
|
||||
listeners.add(pull)
|
||||
pull()
|
||||
if (!peekMediaAccessToken()) {
|
||||
refreshMediaGrant().catch(() => {})
|
||||
}
|
||||
return () => listeners.delete(pull)
|
||||
}, [])
|
||||
return token
|
||||
}
|
||||
|
||||
function withGrant(path, accessToken) {
|
||||
const token = accessToken || peekMediaAccessToken()
|
||||
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
|
||||
return `${API_BASE}${path}${q}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number|string} exerciseId
|
||||
* @param {object} media — exercise_media Zeile mit id, file_path
|
||||
* @param {string} [accessToken]
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function resolveExerciseMediaFileUrl(exerciseId, media) {
|
||||
export function resolveExerciseMediaFileUrl(exerciseId, media, accessToken) {
|
||||
if (!media?.file_path) return null
|
||||
const fp = String(media.file_path)
|
||||
if (fp.startsWith('http://') || fp.startsWith('https://')) return fp
|
||||
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
|
||||
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
|
||||
const id = media.id
|
||||
if (id == null || exerciseId == null) return null
|
||||
return `${API_BASE}/api/exercises/${exerciseId}/media/${id}/file${q}`
|
||||
return withGrant(`/api/exercises/${exerciseId}/media/${id}/file`, accessToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Direkt-URL für Archiv-Asset (Picker/Vorschau ohne exercise_media-Zeile).
|
||||
* @param {number|string} assetId — media_assets.id
|
||||
* @param {string} [accessToken]
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function resolveMediaAssetFileUrl(assetId) {
|
||||
export function resolveMediaAssetFileUrl(assetId, accessToken) {
|
||||
if (assetId == null) return null
|
||||
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
|
||||
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
|
||||
return `${API_BASE}/api/media-assets/${assetId}/file${q}`
|
||||
return withGrant(`/api/media-assets/${assetId}/file`, accessToken)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,111 +1,79 @@
|
|||
/**
|
||||
* Sanitizer für Übungs-Rich-HTML inkl. §11 Platzhalter (span data-shinkan-exercise-media).
|
||||
* Restriktiver als sanitizeTrainerHtml: Allowlist für XSS-Minimierung.
|
||||
* DOMPurify mit fester Allowlist.
|
||||
*/
|
||||
|
||||
import DOMPurify from 'dompurify'
|
||||
import { sanitizeInlineMediaCaption } from './inlineMediaCaption'
|
||||
|
||||
const ALLOWED_BLOCK = new Set(['p', 'div', 'br', 'ul', 'ol', 'li', 'h3'])
|
||||
const ALLOWED_INLINE = new Set(['b', 'strong', 'i', 'em', 'u', 'span', 'a'])
|
||||
const ALLOWED_TAGS = ['p', 'div', 'br', 'ul', 'ol', 'li', 'h3', 'b', 'strong', 'i', 'em', 'u', 'span', 'a']
|
||||
const ALLOWED_ATTR = [
|
||||
'href',
|
||||
'class',
|
||||
'data-shinkan-exercise-media',
|
||||
'data-shinkan-exercise-media-size',
|
||||
'data-shinkan-exercise-media-caption',
|
||||
]
|
||||
const SIZE_OK = new Set(['small', 'medium', 'full'])
|
||||
|
||||
function isHttpsUrl(val) {
|
||||
if (!val || typeof val !== 'string') return false
|
||||
const s = val.trim()
|
||||
return s.startsWith('http://') || s.startsWith('https://')
|
||||
function hookAttribute(_node, data) {
|
||||
const name = data.attrName
|
||||
if (name === 'href') {
|
||||
const href = String(data.attrValue || '').trim()
|
||||
if (!/^https?:\/\//i.test(href)) data.keepAttr = false
|
||||
else data.attrValue = href
|
||||
return
|
||||
}
|
||||
if (name === 'class') {
|
||||
const keep = String(data.attrValue || '')
|
||||
.split(/\s+/)
|
||||
.filter((c) => c === 'shinkan-inline-media')
|
||||
if (!keep.length) data.keepAttr = false
|
||||
else data.attrValue = keep.join(' ')
|
||||
return
|
||||
}
|
||||
if (name === 'data-shinkan-exercise-media') {
|
||||
if (!/^\d+$/.test(String(data.attrValue || '').trim())) data.keepAttr = false
|
||||
return
|
||||
}
|
||||
if (name === 'data-shinkan-exercise-media-size') {
|
||||
const size = String(data.attrValue || '').trim().toLowerCase()
|
||||
if (!SIZE_OK.has(size)) data.keepAttr = false
|
||||
else data.attrValue = size
|
||||
return
|
||||
}
|
||||
if (name === 'data-shinkan-exercise-media-caption') {
|
||||
const caption = sanitizeInlineMediaCaption(String(data.attrValue || ''))
|
||||
if (!caption) data.keepAttr = false
|
||||
else data.attrValue = caption
|
||||
}
|
||||
}
|
||||
|
||||
/** Nur für unsere Embed-Markierung: erlaubt data-attribut und optionale Marker-Klasse + Größe. */
|
||||
const _SIZE_OK = new Set(['small', 'medium', 'full'])
|
||||
function isInlineExerciseMediaPlaceholderSpan(el) {
|
||||
if (!el?.getAttribute || el.tagName.toLowerCase() !== 'span') return false
|
||||
const raw = el.getAttribute('data-shinkan-exercise-media')
|
||||
if (!raw || !String(raw).trim().match(/^\d+$/)) return false
|
||||
return true
|
||||
}
|
||||
const instances = new WeakMap()
|
||||
|
||||
function sanitizeAttributes(el, tagLower) {
|
||||
if (tagLower === 'a') {
|
||||
const href = el.getAttribute('href')
|
||||
if (href && isHttpsUrl(href)) {
|
||||
const out = document.createElement('a')
|
||||
out.setAttribute('href', href.trim())
|
||||
return out.attributes
|
||||
}
|
||||
return []
|
||||
}
|
||||
if (tagLower === 'span' && isInlineExerciseMediaPlaceholderSpan(el)) {
|
||||
const out = document.createElement('span')
|
||||
out.setAttribute('data-shinkan-exercise-media', el.getAttribute('data-shinkan-exercise-media').trim())
|
||||
const sz = (el.getAttribute('data-shinkan-exercise-media-size') || '').trim().toLowerCase()
|
||||
if (sz && _SIZE_OK.has(sz)) {
|
||||
out.setAttribute('data-shinkan-exercise-media-size', sz)
|
||||
}
|
||||
const capRaw = el.getAttribute('data-shinkan-exercise-media-caption')
|
||||
if (capRaw != null && String(capRaw).trim()) {
|
||||
const cap = sanitizeInlineMediaCaption(String(capRaw))
|
||||
if (cap) out.setAttribute('data-shinkan-exercise-media-caption', cap)
|
||||
}
|
||||
const cls = (el.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean)
|
||||
const keep = cls.filter((c) => c === 'shinkan-inline-media')
|
||||
if (keep.length) out.setAttribute('class', keep.join(' '))
|
||||
return out.attributes
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function cloneAsAllowed(tagLower, el) {
|
||||
const fresh = document.createElement(tagLower)
|
||||
const attrs = sanitizeAttributes(el, tagLower)
|
||||
for (const a of attrs) {
|
||||
fresh.setAttribute(a.name, a.value)
|
||||
}
|
||||
return fresh
|
||||
}
|
||||
|
||||
function cleanTree(parent) {
|
||||
const nodes = Array.from(parent.childNodes)
|
||||
for (const node of nodes) {
|
||||
if (node.nodeType === Node.TEXT_NODE) continue
|
||||
if (node.nodeType === Node.COMMENT_NODE) {
|
||||
parent.removeChild(node)
|
||||
continue
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
parent.removeChild(node)
|
||||
continue
|
||||
}
|
||||
const tag = node.tagName.toLowerCase()
|
||||
if (tag === 'script' || tag === 'iframe' || tag === 'object' || tag === 'embed') {
|
||||
parent.removeChild(node)
|
||||
continue
|
||||
}
|
||||
if (ALLOWED_BLOCK.has(tag) || ALLOWED_INLINE.has(tag)) {
|
||||
const repl = cloneAsAllowed(tag, node)
|
||||
while (node.firstChild) {
|
||||
repl.appendChild(node.firstChild)
|
||||
}
|
||||
cleanTree(repl)
|
||||
parent.replaceChild(repl, node)
|
||||
continue
|
||||
}
|
||||
while (node.firstChild) {
|
||||
parent.insertBefore(node.firstChild, node)
|
||||
}
|
||||
parent.removeChild(node)
|
||||
}
|
||||
function purifyFor(win) {
|
||||
const existing = instances.get(win)
|
||||
if (existing) return existing
|
||||
const purify = DOMPurify(win)
|
||||
purify.addHook('uponSanitizeAttribute', hookAttribute)
|
||||
instances.set(win, purify)
|
||||
return purify
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|null|undefined} html
|
||||
* @param {Window=} win
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sanitizeExerciseRichDisplayHtml(html) {
|
||||
export function sanitizeExerciseRichDisplayHtml(html, win) {
|
||||
if (html == null || typeof html !== 'string') return ''
|
||||
const trimmed = html.trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
const tpl = document.createElement('template')
|
||||
tpl.innerHTML = trimmed
|
||||
cleanTree(tpl.content)
|
||||
return tpl.innerHTML
|
||||
const target = win || (typeof window !== 'undefined' ? window : null)
|
||||
if (!target) return ''
|
||||
return purifyFor(target).sanitize(trimmed, {
|
||||
ALLOWED_TAGS,
|
||||
ALLOWED_ATTR,
|
||||
ALLOW_DATA_ATTR: false,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
21
frontend/src/utils/exerciseRichTextSanitize.test.js
Normal file
21
frontend/src/utils/exerciseRichTextSanitize.test.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { JSDOM } from 'jsdom'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sanitizeExerciseRichDisplayHtml } from './exerciseRichTextSanitize'
|
||||
|
||||
const { window } = new JSDOM('<!doctype html><html><body></body></html>')
|
||||
|
||||
describe('sanitizeExerciseRichDisplayHtml', () => {
|
||||
it('entfernt Skripte und Handler und behält den Medien-Platzhalter', () => {
|
||||
const dirty =
|
||||
'<p onclick="alert(1)">Hallo <script>alert(1)</script></p>' +
|
||||
'<a href="javascript:alert(1)">Link</a>' +
|
||||
'<span class="shinkan-inline-media evil" data-shinkan-exercise-media="7" data-shinkan-exercise-media-size="full"></span>'
|
||||
const clean = sanitizeExerciseRichDisplayHtml(dirty, window)
|
||||
expect(clean.toLowerCase()).not.toContain('script')
|
||||
expect(clean.toLowerCase()).not.toContain('onclick')
|
||||
expect(clean.toLowerCase()).not.toContain('javascript:')
|
||||
expect(clean).toContain('Hallo')
|
||||
expect(clean).toContain('data-shinkan-exercise-media="7"')
|
||||
expect(clean).not.toContain('evil')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
/** Einfache HTML-Hilfen für Rich-Text (Trainer-Content, kein öffentliches CMS). */
|
||||
|
||||
import { sanitizeExerciseRichDisplayHtml } from './exerciseRichTextSanitize'
|
||||
|
||||
export function stripHtmlToText(html) {
|
||||
if (!html || typeof html !== 'string') return ''
|
||||
const d = document.createElement('div')
|
||||
|
|
@ -7,17 +9,7 @@ export function stripHtmlToText(html) {
|
|||
return (d.textContent || '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Entfernt script/iframes und Event-Handler-Attribute grob. */
|
||||
/** Entfernt alles außerhalb der Übungs-Allowlist. */
|
||||
export function sanitizeTrainerHtml(html) {
|
||||
if (!html || typeof html !== 'string') return ''
|
||||
const d = document.createElement('div')
|
||||
d.innerHTML = html
|
||||
d.querySelectorAll('script, iframe, object, embed').forEach((n) => n.remove())
|
||||
d.querySelectorAll('*').forEach((el) => {
|
||||
for (const attr of [...el.attributes]) {
|
||||
const n = attr.name.toLowerCase()
|
||||
if (n.startsWith('on') || n === 'srcdoc') el.removeAttribute(attr.name)
|
||||
}
|
||||
})
|
||||
return d.innerHTML
|
||||
return sanitizeExerciseRichDisplayHtml(html)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,11 @@
|
|||
/**
|
||||
* Reduziert HTML aus Übungs-Kurztexten auf eine kleine erlaubte Menge von Tags (ohne Attribute).
|
||||
* Für Anzeige mit dangerouslySetInnerHTML.
|
||||
* Reduziert HTML aus Übungs-Kurztexten auf die Allowlist von sanitizeExerciseRichDisplayHtml.
|
||||
*/
|
||||
const ALLOWED_TAGS = new Set(['b', 'strong', 'i', 'em', 'br', 'p', 'span', 'ul', 'ol', 'li'])
|
||||
|
||||
function cleanTree(parent) {
|
||||
const nodes = Array.from(parent.childNodes)
|
||||
for (const node of nodes) {
|
||||
if (node.nodeType === Node.TEXT_NODE) continue
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
parent.removeChild(node)
|
||||
continue
|
||||
}
|
||||
const tag = node.tagName.toLowerCase()
|
||||
if (!ALLOWED_TAGS.has(tag)) {
|
||||
while (node.firstChild) {
|
||||
parent.insertBefore(node.firstChild, node)
|
||||
}
|
||||
parent.removeChild(node)
|
||||
continue
|
||||
}
|
||||
while (node.attributes.length > 0) {
|
||||
node.removeAttribute(node.attributes[0].name)
|
||||
}
|
||||
cleanTree(node)
|
||||
}
|
||||
}
|
||||
import { sanitizeExerciseRichDisplayHtml } from './exerciseRichTextSanitize'
|
||||
|
||||
export function sanitizeExerciseRichText(html) {
|
||||
if (html == null || typeof html !== 'string') return ''
|
||||
const trimmed = html.trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
const tpl = document.createElement('template')
|
||||
tpl.innerHTML = trimmed
|
||||
cleanTree(tpl.content)
|
||||
return tpl.innerHTML
|
||||
return sanitizeExerciseRichDisplayHtml(html)
|
||||
}
|
||||
|
||||
export function coerceApiNameList(value) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user