Implement RBAC for library content management in club tenancy
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Has been cancelled

- Introduced new functions for managing edit, delete, and governance transition permissions for library content, aligning with role-based access control (RBAC) principles.
- Updated existing routers to utilize these new functions, ensuring consistent permission checks across training frameworks, modules, and progression graphs.
- Enhanced visibility and governance handling for training plan templates and library content, improving overall content management and user experience.
- Incremented app version to 0.8.142 and updated changelog to reflect these changes.
This commit is contained in:
Lars 2026-05-16 10:53:00 +02:00
parent bc1790bd82
commit 0275f76432
9 changed files with 368 additions and 64 deletions

View File

@ -3,7 +3,7 @@ Vereins-Mandanten: Mitgliedschaften, aktiver Vereinskontext, einfache Berechtigu
Siehe .claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md
"""
from typing import Any, Dict, List, Optional, Set
from typing import Any, Dict, List, Mapping, Optional, Set, Union
from fastapi import HTTPException
@ -155,6 +155,165 @@ def club_ids_for_profile_with_roles(cur, profile_id: int, *role_codes: str) -> S
_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"})
def _library_governance_triplet(
row: Mapping[str, Any],
) -> tuple[str, Optional[int], Optional[int]]:
"""visibility, club_id, created_by als normalisierte Werte für Bibliotheks-/Planungsartefakte."""
vis = str(row.get("visibility") or "private").strip().lower()
if vis not in _GOVERNANCE_VISIBILITY:
vis = "private"
cid_raw = row.get("club_id")
try:
ex_cid = int(cid_raw) if cid_raw is not None else None
except (TypeError, ValueError):
ex_cid = None
cr_raw = row.get("created_by")
try:
creator = int(cr_raw) if cr_raw is not None else None
except (TypeError, ValueError):
creator = None
return vis, ex_cid, creator
def assert_library_content_editable(
cur,
profile_id: int,
role: Optional[str],
row: Union[Dict[str, Any], Mapping[str, Any]],
) -> None:
"""Inhalt bearbeiten: wie Übungen — Ersteller, Plattform-Admin oder Planungsberechtigter im Verein."""
pid = int(profile_id)
ex_vis, ex_cid, creator = _library_governance_triplet(row)
if creator is not None and creator == pid:
return
if is_platform_admin(role):
return
if ex_vis == "club" and ex_cid is not None and can_plan_in_club(cur, pid, ex_cid, role):
return
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Bearbeiten dieses Inhalts")
def assert_library_content_deletable(
cur,
profile_id: int,
role: Optional[str],
row: Union[Dict[str, Any], Mapping[str, Any]],
) -> None:
"""Löschen: wie Übungen — privat Eigentümer/Vereins-Admin-Kontext, Verein nur Vereinsadmin, offiziell nur Plattform."""
pid = int(profile_id)
if is_platform_admin(role):
return
vis, cid, creator = _library_governance_triplet(row)
try:
creator_int = int(creator) if creator is not None else None
except (TypeError, ValueError):
creator_int = None
if vis == "official":
raise HTTPException(
status_code=403,
detail="Offizielle Inhalte dürfen nur von Plattform-Admins gelöscht werden.",
)
if vis == "club":
try:
ex_club = int(cid) if cid is not None else None
except (TypeError, ValueError):
ex_club = None
if ex_club is None:
raise HTTPException(status_code=400, detail="Vereinsinhalt ohne gültige Vereinszuordnung")
if not has_club_role(cur, pid, ex_club, "club_admin"):
raise HTTPException(
status_code=403,
detail="Nur Vereins-Admins dürfen Vereins-Inhalte löschen.",
)
return
if creator_int is not None and creator_int == pid:
return
if creator_int is not None and club_admin_shares_club_with_creator(cur, pid, creator_int):
return
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Löschen dieses Inhalts")
def assert_library_content_governance_transition(
cur,
profile_id: int,
role: Optional[str],
prev_row: Union[Dict[str, Any], Mapping[str, Any]],
next_visibility: str,
next_club_id: Optional[int],
) -> None:
"""
Zusätzliche Regeln beim Ändern von visibility/club_id (Zielzustand vor assert_valid_governance_visibility prüfen).
- Abwahl official: nur Plattform-Admin.
- private club: nur Ersteller (oder Plattform-Admin).
- club private: Ersteller, Vereinsadmin im bisherigen Verein oder Plattform-Admin.
- club club mit Wechsel club_id: Vereinsadmin im alten oder neuen Verein oder Plattform-Admin.
"""
nv = str(next_visibility or "").strip().lower()
if nv not in _GOVERNANCE_VISIBILITY:
raise HTTPException(status_code=400, detail="Ungültige visibility")
old_vis, old_cid, creator = _library_governance_triplet(prev_row)
new_cid: Optional[int]
try:
new_cid = int(next_club_id) if next_club_id is not None else None
except (TypeError, ValueError):
new_cid = None
pid = int(profile_id)
try:
creator_int = int(creator) if creator is not None else None
except (TypeError, ValueError):
creator_int = None
if old_vis == nv and (nv != "club" or old_cid == new_cid):
return
if old_vis == "official" and nv != "official":
if not is_platform_admin(role):
raise HTTPException(
status_code=403,
detail="Nur Plattform-Admins dürfen offizielle Inhalte auf Verein oder privat setzen.",
)
if nv == "official":
return
if old_vis == "private" and nv == "club":
if creator_int is not None and creator_int != pid and not is_platform_admin(role):
raise HTTPException(
status_code=403,
detail="Nur der Ersteller darf private Inhalte für den Verein freigeben.",
)
return
if old_vis == "club" and nv == "private":
if is_platform_admin(role):
return
if creator_int is not None and creator_int == pid:
return
if old_cid is not None and has_club_role(cur, pid, old_cid, "club_admin"):
return
raise HTTPException(
status_code=403,
detail="Nur Ersteller, Vereins-Admins oder Plattform-Admins dürfen Vereins-Inhalte auf privat setzen.",
)
if old_vis == "club" and nv == "club" and old_cid != new_cid:
if is_platform_admin(role):
return
ok_old = old_cid is not None and has_club_role(cur, pid, old_cid, "club_admin")
ok_new = new_cid is not None and has_club_role(cur, pid, new_cid, "club_admin")
if ok_old or ok_new:
return
raise HTTPException(
status_code=403,
detail="Nur Vereins-Admins oder Plattform-Admins dürfen die Vereinszuordnung ändern.",
)
def assert_valid_governance_visibility(
cur,
profile_id: int,

View File

@ -1,7 +1,7 @@
"""
Progressionsgraph zwischen Übungen (Übung Übung), Migration 032034.
Optional Übungsvarianten als Knoten-Endpunkte; Sequenz-Bulk-Anlage.
AuthZ analog training_plan_templates (_template_access / _has_planning_role).
AuthZ analog training_plan_templates: Bearbeiten/Löschen wie Übungen; Governance-Übergänge zentral.
"""
from typing import Any, List, Optional
@ -12,8 +12,10 @@ from psycopg2 import IntegrityError
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_library_content_deletable,
assert_library_content_editable,
assert_library_content_governance_transition,
assert_valid_governance_visibility,
is_platform_admin,
library_content_visible_to_profile,
)
@ -111,13 +113,7 @@ def _assert_graph_readable(cur, row: dict, profile_id: int, role: str) -> None:
def _assert_graph_writable(cur, row: dict, profile_id: int, role: str) -> None:
if is_platform_admin(role):
return
created_by = row.get("created_by")
if created_by is not None:
created_by = int(created_by)
if created_by != profile_id:
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Progressionsgraph")
assert_library_content_editable(cur, profile_id, role, row)
def _require_graph_read(cur, graph_id: int, profile_id: int, role: str) -> dict:
@ -333,6 +329,7 @@ def update_progression_graph(
)
gov_club = next_club if next_vis == "club" else None
assert_library_content_governance_transition(cur, profile_id, role, row, next_vis, gov_club)
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
fields: List[str] = []
@ -376,7 +373,9 @@ def delete_progression_graph(graph_id: int, tenant: TenantContext = Depends(get_
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
_require_graph_write(cur, graph_id, profile_id, role)
row = _graph_row(cur, graph_id)
_assert_graph_readable(cur, row, profile_id, role)
assert_library_content_deletable(cur, profile_id, role, row)
cur.execute("DELETE FROM exercise_progression_graphs WHERE id = %s", (graph_id,))
conn.commit()
return {"ok": True}

View File

@ -3,13 +3,16 @@ Trainingsrahmenprogramm — wiederverwendbare Vorlage (Bibliothek) über mehrere
Zuordnung zu Trainingsgruppen / konkreten Einheiten erfolgt aus der Planung (Kopie + Lineage),
nicht über group_id oder training_unit_id am Rahmen.
Lesen wie Übungen (official / private / club); Schreiben nur Ersteller oder Plattform-Admin.
Lesen wie Übungen (official / private / club); Bearbeiten wie Übungen; Löschen nach Rolle (s. club_tenancy).
"""
from typing import Any, Dict, List, Optional, Sequence
from fastapi import APIRouter, Depends, HTTPException
from club_tenancy import (
assert_library_content_deletable,
assert_library_content_editable,
assert_library_content_governance_transition,
assert_valid_governance_visibility,
is_platform_admin,
library_content_visible_to_profile,
@ -56,13 +59,6 @@ def _framework_assert_readable(
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen")
def _framework_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
if is_platform_admin(role):
return
if row.get("created_by") != profile_id:
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen")
def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
row = _fetch_framework_row(cur, framework_id)
_framework_assert_readable(cur, row, profile_id, role)
@ -459,7 +455,7 @@ def update_training_framework_program(
with get_db() as conn:
cur = get_cursor(conn)
row_prev = _fetch_framework_row(cur, framework_id)
_framework_assert_writable(cur, row_prev, profile_id, role)
assert_library_content_editable(cur, profile_id, role, row_prev)
merged_vis = row_prev.get("visibility") or "private"
merged_club = row_prev.get("club_id")
@ -472,7 +468,12 @@ def update_training_framework_program(
merged_club = data.get("club_id")
if merged_club in ("", []):
merged_club = None
if merged_vis == "club" and merged_club is None:
merged_club = tenant.effective_club_id
if "visibility" in data or "club_id" in data:
assert_library_content_governance_transition(
cur, profile_id, role, row_prev, merged_vis, merged_club
)
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
header_fields = []
@ -574,7 +575,7 @@ def delete_training_framework_program(
with get_db() as conn:
cur = get_cursor(conn)
row_fw = _fetch_framework_row(cur, framework_id)
_framework_assert_writable(cur, row_fw, profile_id, role)
assert_library_content_deletable(cur, profile_id, role, row_fw)
cur.execute(
"DELETE FROM training_framework_programs WHERE id = %s",
(framework_id,),

View File

@ -2,7 +2,7 @@
Trainingsmodule wiederverwendbare Planungsbausteine (Bibliothek).
Governance wie TrainingsMikrovorlagen (`training_plan_templates`):
Liste/Detail über `library_content_visibility_sql`; Schreiben: Ersteller oder PlattformAdmin.
Liste/Detail über `library_content_visibility_sql`; Bearbeiten/Löschen wie Übungen (s. club_tenancy).
Siehe `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md`.
"""
@ -13,6 +13,9 @@ from fastapi import APIRouter, Depends, HTTPException
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_library_content_deletable,
assert_library_content_editable,
assert_library_content_governance_transition,
assert_valid_governance_visibility,
is_platform_admin,
library_content_visible_to_profile,
@ -47,13 +50,6 @@ def _module_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Opt
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Modul")
def _module_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
if is_platform_admin(role):
return
if row.get("created_by") != profile_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller darf dieses Modul ändern")
def _module_access(cur, mid: int, profile_id: int, role: str) -> Dict[str, Any]:
row = _fetch_training_module_row(cur, mid)
_module_assert_readable(cur, row, profile_id, role)
@ -288,7 +284,7 @@ def update_training_module(
with get_db() as conn:
cur = get_cursor(conn)
row_prev = _fetch_training_module_row(cur, module_id)
_module_assert_writable(cur, row_prev, profile_id, role)
assert_library_content_editable(cur, profile_id, role, row_prev)
merged_vis = row_prev.get("visibility") or "club"
merged_club = row_prev.get("club_id")
@ -303,8 +299,12 @@ def update_training_module(
merged_club = data.get("club_id")
if merged_club in ("", []):
merged_club = None
if merged_vis == "club" and merged_club is None:
merged_club = tenant.effective_club_id
if "visibility" in data or "club_id" in data:
assert_library_content_governance_transition(
cur, profile_id, role, row_prev, merged_vis, merged_club
)
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
fields: List[str] = []
@ -375,7 +375,7 @@ def delete_training_module(module_id: int, tenant: TenantContext = Depends(get_t
with get_db() as conn:
cur = get_cursor(conn)
row_del = _fetch_training_module_row(cur, module_id)
_module_assert_writable(cur, row_del, profile_id, role)
assert_library_content_deletable(cur, profile_id, role, row_del)
cur.execute("DELETE FROM training_modules WHERE id = %s", (module_id,))
conn.commit()
return {"ok": True}

View File

@ -2,7 +2,7 @@
Training Planning Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen)
und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin.
Governance: Sichtbarkeit wie Übungen (private / club / official); Bearbeiten wie Übungen; Löschen nach Rolle (s. club_tenancy).
"""
from datetime import date, datetime, time as dt_time, timedelta
from typing import Any, Dict, List, Optional, Tuple
@ -15,6 +15,9 @@ from fastapi_param_unwrap import unwrap_query_default
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from club_tenancy import (
assert_library_content_deletable,
assert_library_content_editable,
assert_library_content_governance_transition,
assert_valid_governance_visibility,
can_manage_club_org,
is_platform_admin,
@ -1748,13 +1751,6 @@ def _template_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: O
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Vorlage")
def _template_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None:
if is_platform_admin(role):
return
if row.get("created_by") != profile_id:
raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Vorlage ändern")
def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]:
"""Lesender Zugriff (Liste der Vorlage für Einheit); Schreiben: _template_assert_writable."""
row = _fetch_training_plan_template_row(cur, tid)
@ -1853,7 +1849,7 @@ def update_training_plan_template(template_id: int, data: dict, tenant: TenantCo
with get_db() as conn:
cur = get_cursor(conn)
row_prev = _fetch_training_plan_template_row(cur, template_id)
_template_assert_writable(cur, row_prev, profile_id, role)
assert_library_content_editable(cur, profile_id, role, row_prev)
merged_vis = row_prev.get("visibility") or "club"
merged_club = row_prev.get("club_id")
if "visibility" in data:
@ -1865,7 +1861,12 @@ def update_training_plan_template(template_id: int, data: dict, tenant: TenantCo
merged_club = data.get("club_id")
if merged_club in ("", []):
merged_club = None
if merged_vis == "club" and merged_club is None:
merged_club = tenant.effective_club_id
if "visibility" in data or "club_id" in data:
assert_library_content_governance_transition(
cur, profile_id, role, row_prev, merged_vis, merged_club
)
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
fields = []
params: List[Any] = []
@ -1911,7 +1912,7 @@ def delete_training_plan_template(template_id: int, tenant: TenantContext = Depe
with get_db() as conn:
cur = get_cursor(conn)
row_del = _fetch_training_plan_template_row(cur, template_id)
_template_assert_writable(cur, row_del, profile_id, role)
assert_library_content_deletable(cur, profile_id, role, row_del)
cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,))
conn.commit()
return {"ok": True}

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.141"
APP_VERSION = "0.8.142"
BUILD_DATE = "2026-05-14"
DB_SCHEMA_VERSION = "20260515064"
@ -24,9 +24,9 @@ MODULE_VERSIONS = {
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
"training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak
"training_programs": "0.1.0",
"planning": "0.12.0", # Trainingsvorlagen: Phasen/Streams in template_sections (064); Instantiate über _replace_unit_phases
"planning": "0.13.0", # Vorlagen/Framework/Module/Graphs: RBAC wie Übungen (edit/delete/governance transition); Planungs-UI Sichtbarkeit neue Vorlage
"dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine)
"training_modules": "1.0.0",
"training_modules": "1.1.0", # PUT/DELETE: assert_library_content_* (Vereinsadmin löscht Vereins-Inhalt, Trainer bearbeitet club wie Übungen)
"import_wiki": "1.0.0",
"admin": "1.0.0",
"membership": "1.0.0",
@ -36,6 +36,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.142",
"date": "2026-05-16",
"changes": [
"Bibliothek Planung: Bearbeiten/Löschen von Vorlagen, Rahmenprogrammen, Trainingsmodulen, Progressionsgraphen nach RBAC wie Übungen (club_tenancy.assert_library_content_* + Governance-Übergänge)",
"Planungs-UI: Sichtbarkeit/Verein beim Speichern neuer Trainingsvorlage",
],
},
{
"version": "0.8.141",
"date": "2026-05-14",

View File

@ -643,12 +643,31 @@ function TrainingPlanningPageRoot() {
})
}, [user?.id, loading, searchParams, handleEdit, setSearchParams])
const handleSaveAsTemplate = async () => {
const handleSaveAsTemplate = async (opts = {}) => {
const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
if (!name?.trim()) return
const visibility =
typeof opts.visibility === 'string' && opts.visibility.trim()
? String(opts.visibility).trim().toLowerCase()
: 'private'
let club_id = opts.club_id != null && opts.club_id !== '' ? Number(opts.club_id) : null
if (visibility === 'club') {
if (!Number.isFinite(club_id) || club_id < 1) {
const fb = planningModalClubId != null ? Number(planningModalClubId) : NaN
if (Number.isFinite(fb) && fb >= 1) club_id = fb
}
if (!Number.isFinite(club_id) || club_id < 1) {
toast.error('Bitte einen Verein wählen (Sichtbarkeit „Verein“).')
return
}
} else {
club_id = null
}
try {
await api.createTrainingPlanTemplate({
name: name.trim(),
visibility,
club_id: visibility === 'club' ? club_id : null,
sections: templateSectionsPayloadFromFormSections(formData.sections),
})
await loadPlanTemplates()

View File

@ -1,7 +1,9 @@
import React from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
import { activeClubMemberships } from '../../utils/activeClub'
import { canDeleteLibraryContent } from '../../utils/libraryContentPermissions'
import { frameworkLineageText } from '../../utils/trainingPlanningPageHelpers'
/**
@ -31,6 +33,22 @@ export default function TrainingPlanningUnitFormModal({
onRequestExercisePick,
onPeekExercise,
}) {
const [newTplVisibility, setNewTplVisibility] = useState('private')
const [newTplClubId, setNewTplClubId] = useState('')
const memberClubs = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
const roleLc = String(user?.role || '').toLowerCase()
const isSuperadmin = roleLc === 'superadmin'
useEffect(() => {
if (!open) return
if (planningModalClubId != null && planningModalClubId !== '') {
setNewTplClubId(String(planningModalClubId))
} else if (memberClubs.length === 1) {
setNewTplClubId(String(memberClubs[0].id))
}
}, [open, planningModalClubId, memberClubs])
if (!open) return null
return (
@ -116,12 +134,17 @@ export default function TrainingPlanningUnitFormModal({
onChange={(e) => onDraftTemplateSelect(e.target.value)}
>
<option value="">Ohne Vorlage leere Gliederung (ein Abschnitt)</option>
{planTemplates.map((t) => (
<option key={t.id} value={String(t.id)}>
{t.name}
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}
</option>
))}
{planTemplates.map((t) => {
const v = String(t.visibility || 'club').toLowerCase()
const vLabel = v === 'private' ? 'Privat' : v === 'official' ? 'Offiziell' : 'Verein'
return (
<option key={t.id} value={String(t.id)}>
{t.name}
{typeof t.sections_count === 'number' ? ` · ${t.sections_count} Abschn.` : ''}{' '}
· {vLabel}
</option>
)
})}
</select>
<p className="training-planning-template-panel__help">
Übernimmt nur die <strong>Sektionsstruktur</strong> aus der Bibliothek; Übungen trägst du unten bei den
@ -144,17 +167,12 @@ export default function TrainingPlanningUnitFormModal({
Gespeicherte Vorlagen löschen
</summary>
<p style={{ margin: '0.65rem 0 0.75rem', fontSize: '0.82rem', color: 'var(--text2)', lineHeight: 1.45 }}>
Du kannst eigene Vorlagen entfernen. Plattform-Admins dürfen auch fremde Vorlagen löschen. Einheiten, die
noch auf eine Vorlage verweisen, behalten ihren Ablauf; die Verknüpfung zur Vorlage wird vom Server
entfernt.
Entfernen nach Rolle: eigene private Vorlagen; Vereins­inhalte als Vereins­admin; offizielle nur als
PlattformAdmin. Einheiten mit Verweis behalten den Ablauf; die Vorlage wird entkoppelt.
</p>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{planTemplates.map((t, ti) => {
const roleLc = String(user?.role || '').toLowerCase()
const isPlatformAdmin = roleLc === 'admin' || roleLc === 'superadmin'
const canDel =
user &&
(isPlatformAdmin || Number(t.created_by) === Number(user.id))
const canDel = user && canDeleteLibraryContent(user, t)
return (
<li
key={t.id}
@ -169,6 +187,15 @@ export default function TrainingPlanningUnitFormModal({
>
<span style={{ minWidth: 0, flex: 1, fontSize: '0.9rem' }}>
<strong style={{ color: 'var(--text1)' }}>{t.name}</strong>
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', marginLeft: '6px' }}>
(
{String(t.visibility || 'club').toLowerCase() === 'private'
? 'Privat'
: String(t.visibility || 'club').toLowerCase() === 'official'
? 'Offiziell'
: 'Verein'}
)
</span>
{typeof t.sections_count === 'number' ? (
<span style={{ fontSize: '0.82rem', color: 'var(--text2)', marginLeft: '6px' }}>
· {t.sections_count} Abschn.
@ -401,9 +428,71 @@ export default function TrainingPlanningUnitFormModal({
heading="Abschnitte & Übungen"
headingAccessory={
<>
<button type="button" className="btn btn-secondary" onClick={onSaveAsTemplate}>
Vorlage aus Aufbau speichern
</button>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-end',
gap: '10px',
marginBottom: '10px',
}}
>
<div className="form-row" style={{ marginBottom: 0, minWidth: 'min(160px, 100%)' }}>
<label className="form-label" style={{ fontSize: '0.82rem' }}>
Neue Vorlage: Sichtbarkeit
</label>
<select
className="form-input"
value={newTplVisibility}
onChange={(e) => {
const v = e.target.value
setNewTplVisibility(v)
if (v === 'club' && !newTplClubId && planningModalClubId != null) {
setNewTplClubId(String(planningModalClubId))
}
}}
>
<option value="private">Privat (nur du)</option>
<option value="club">Verein</option>
{isSuperadmin ? <option value="official">Offiziell (global)</option> : null}
</select>
</div>
{newTplVisibility === 'club' ? (
<div className="form-row" style={{ marginBottom: 0, flex: '1 1 200px' }}>
<label className="form-label" style={{ fontSize: '0.82rem' }}>
Verein
</label>
<select
className="form-input"
value={newTplClubId}
onChange={(e) => setNewTplClubId(e.target.value)}
>
<option value=""> Verein wählen </option>
{memberClubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `Verein #${c.id}`}
</option>
))}
</select>
</div>
) : null}
<button
type="button"
className="btn btn-secondary"
style={{ marginBottom: '2px' }}
onClick={() =>
onSaveAsTemplate?.({
visibility: newTplVisibility,
club_id:
newTplVisibility === 'club' && newTplClubId
? parseInt(newTplClubId, 10)
: null,
})
}
>
Vorlage aus Aufbau speichern
</button>
</div>
</>
}
sections={formData.sections}

View File

@ -0,0 +1,28 @@
import { activeClubMemberships } from './activeClub'
export function clubAdminInClub(user, clubId) {
const cid = Number(clubId)
if (!Number.isFinite(cid)) return false
const row = activeClubMemberships(user?.clubs).find((c) => Number(c.id) === cid)
return Boolean(row?.roles?.includes('club_admin'))
}
/**
* Löschen von Bibliotheks-/Planungsinhalten (Vorlage, Modul, Rahmen, Graph) grob wie Backend club_tenancy.
* Vereins-Admins können fremde private Einträge im API löschen (gemeinsamer Verein); das blenden wir hier nicht ein.
*/
export function canDeleteLibraryContent(user, row) {
const grole = String(user?.role || '').toLowerCase()
if (grole === 'admin' || grole === 'superadmin') return true
const uid = Number(user?.id)
if (!Number.isFinite(uid)) return false
const vis = String(row?.visibility ?? 'club').toLowerCase()
const createdBy = row?.created_by != null ? Number(row.created_by) : null
const clubId = row?.club_id != null ? Number(row.club_id) : null
if (vis === 'official') return false
if (vis === 'club') return clubAdminInClub(user, clubId)
if (vis === 'private') return Number.isFinite(createdBy) && createdBy === uid
return false
}