mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-17 16:40:31 +00:00
✨ 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:
@@ -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') }}
|
||||
|
||||
Reference in New Issue
Block a user