Implement leadership-based permissions for Meetup management

- 🔒 Restrict event creation, editing, and deletion to Meetup leaders (`is_leader`) and creators for consistency across APIs, frontend, and MCP.
-  Add new APIs for leader delegation: assign/remove Meetup leaders via `meetup_user.is_leader`.
- 🛠️ Replace loose member checks with specific leadership checks in policies, controllers, and views.
- 🧪 Add exhaustive tests to ensure only eligible leaders execute critical actions (e.g., event creation/edit, Meetup updates).
- 🔄 Refactor pivot relationships and models (`leadByMe`, `isLeader`) for explicit leadership handling.
-  Introduce artisan command `meetups:promote-existing-leaders` to transition legacy data.
This commit is contained in:
HolgerHatGarKeineNode
2026-06-16 22:04:34 +02:00
parent 39af153f52
commit 9f8fda294a
26 changed files with 691 additions and 70 deletions
+14 -9
View File
@@ -179,15 +179,20 @@ class extends Component {
</a>
</div>
<div class="flex flex-row items-center gap-2 w-full sm:w-auto">
<flux:button
:href="route_with_country('meetups.events.create', ['meetup' => $meetup])"
variant="primary" icon="calendar" size="xs">
{{ __('Neues Event erstellen') }}
</flux:button>
<flux:button :href="route_with_country('meetups.edit', ['meetup' => $meetup])"
size="xs" variant="ghost" icon="pencil" class="w-full sm:w-auto">
{{ __('Bearbeiten') }}
</flux:button>
{{-- Termine & Stammdaten nur für Leader dieses Meetups
(meetup_user.is_leader); Mitglieder ohne Leaderschaft
können das Meetup nur aus „Meine“ entfernen. --}}
@if($meetup->pivot->is_leader)
<flux:button
:href="route_with_country('meetups.events.create', ['meetup' => $meetup])"
variant="primary" icon="calendar" size="xs">
{{ __('Neues Event erstellen') }}
</flux:button>
<flux:button :href="route_with_country('meetups.edit', ['meetup' => $meetup])"
size="xs" variant="ghost" icon="pencil" class="w-full sm:w-auto">
{{ __('Bearbeiten') }}
</flux:button>
@endif
<flux:modal.trigger :name="'remove-meetup-' . $meetup->id">
<flux:button class="cursor-pointer" size="xs" variant="danger"
icon="trash"></flux:button>
@@ -44,7 +44,11 @@ class extends Component {
// Ensure timezone is always set - use fallback if not initialized yet
$timezone = $this->userTimezone ?: (auth()->user()->timezone ?? 'Europe/Berlin');
$startDate = \Carbon\Carbon::createFromFormat('Y-m-d H:i', $this->startDate . ' ' . $this->startTime, $timezone);
$endDate = \Carbon\Carbon::createFromFormat('Y-m-d', $this->endDate, $timezone);
// Enddatum kommt aus einem reinen Datums-Picker: bis zum Ende des
// gewählten Tages (inklusiv). endOfDay() macht das deterministisch —
// createFromFormat('Y-m-d', …) würde sonst die AKTUELLE Uhrzeit
// einsetzen und das letzte Vorkommen je nach Laufzeit ein-/ausschließen.
$endDate = \Carbon\Carbon::createFromFormat('Y-m-d', $this->endDate, $timezone)->endOfDay();
return array_map(fn (\Carbon\Carbon $date): array => [
'date' => $date,
@@ -97,8 +101,21 @@ class extends Component {
#[Validate('required|url|max:255')]
public ?string $link = null;
/**
* Termine darf nur verwalten, wer das zugehörige Meetup bearbeiten darf
* (Ersteller/Leader/Super-Admin) dieselbe update-Ability wie die
* Stammdaten. Spiegelt meetups.edit::authorizeAccess().
*/
protected function authorizeManage(): void
{
if (auth()->guest() || auth()->user()->cannot('update', $this->meetup)) {
abort(403);
}
}
public function mount(): void
{
$this->authorizeManage();
$this->country = request()->route('country', config('app.domain_country'));
$this->userTimezone = auth()->user()->timezone ?? 'Europe/Berlin';
$timezone = $this->userTimezone;
@@ -130,6 +147,8 @@ class extends Component {
public function save(): void
{
$this->authorizeManage();
$validationRules = [
'startDate' => 'required|date',
'startTime' => 'required',
@@ -191,7 +210,9 @@ class extends Component {
private function createEventSeries(string $timezone): void
{
$startDate = \Carbon\Carbon::createFromFormat('Y-m-d H:i', $this->startDate . ' ' . $this->startTime, $timezone);
$endDate = \Carbon\Carbon::createFromFormat('Y-m-d', $this->endDate, $timezone);
// Inklusiv bis zum Ende des gewählten Tages, deterministisch (siehe
// getPreviewDatesProperty) — Vorschau und Anlegen erzeugen so dieselbe Liste.
$endDate = \Carbon\Carbon::createFromFormat('Y-m-d', $this->endDate, $timezone)->endOfDay();
$eventsCreated = 0;
@@ -85,15 +85,13 @@ class extends Component {
}
/**
* Portal-Frontend nutzt die gelockerte updateViaPortal-Ability: Ersteller,
* Super-Admins UND Mitglieder der meetup_user-Pivot („Meine Meetups") dürfen
* die Stammdaten bearbeiten. REST-API und MCP-Tools bleiben auf der strikten
* update()-Ability (nur Ersteller/Super-Admin). Übergangslösung, bis ein
* echtes Rollen-/Freigabekonzept existiert.
* Stammdaten bearbeiten dürfen der Ersteller, Super-Admins UND delegierte
* Leader (meetup_user.is_leader). Einheitliche update-Ability für Portal-
* Frontend, REST-API und MCP-Tools (MeetupPolicy::update).
*/
protected function authorizeAccess(): void
{
if (auth()->guest() || auth()->user()->cannot('updateViaPortal', $this->meetup)) {
if (auth()->guest() || auth()->user()->cannot('update', $this->meetup)) {
abort(403);
}
}
@@ -185,11 +185,10 @@ class extends Component {
<flux:table.cell>
<div class="flex flex-col space-y-2">
@if(auth()->check() && $meetup->belongsToMe)
@if(auth()->check() && $meetup->leadByMe)
<div>
<flux:button
:disabled="!$meetup->belongsToMe"
:href="$meetup->belongsToMe ? route_with_country('meetups.edit', ['meetup' => $meetup]) : null"
:href="route_with_country('meetups.edit', ['meetup' => $meetup])"
size="xs"
variant="filled" icon="pencil">
{{ __('Bearbeiten') }}
@@ -23,7 +23,7 @@ class extends Component {
public function deleteEvent(MeetupEvent $event): void
{
if ($this->meetup->belongsToMe) {
if ($this->meetup->leadByMe) {
$event->delete();
$this->dispatch('event-deleted');
Flux::modals()->close();
@@ -231,7 +231,7 @@ class extends Component {
<div class="mt-16">
<div class="flex flex-col sm:flex-row items-center sm:space-x-4 space-y-4 sm:space-y-0 mb-6">
<flux:heading size="xl">{{ __('Kommende Veranstaltungen') }}</flux:heading>
@if(auth()->user() && auth()->user()->meetups()->find($meetup->id)?->exists)
@if($meetup->leadByMe)
<flux:button :href="route_with_country('meetups.events.create', ['meetup' => $meetup])"
variant="primary" icon="calendar">
{{ __('Neues Event erstellen') }}
@@ -281,7 +281,7 @@ class extends Component {
>
{{ __('Öffnen/RSVP') }}
</flux:button>
@if($meetup->belongsToMe)
@if($meetup->leadByMe)
<flux:button
:href="route_with_country('meetups.events.edit', ['meetup' => $meetup, 'event' => $event])"
size="xs"
@@ -332,7 +332,7 @@ class extends Component {
@else
<div class="mt-16">
<div class="flex items-center space-x-4 mb-6">
@if(auth()->user() && auth()->user()->meetups()->find($meetup->id)?->exists)
@if($meetup->leadByMe)
<flux:button :href="route_with_country('meetups.events.create', ['meetup' => $meetup])"
variant="primary" icon="calendar">
{{ __('Neues Event erstellen') }}