mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-18 17:00:31 +00:00
✨ Add leader management functionality for Meetups
- ➕ Introduced `leaders`, `promoteLeader`, and `demoteLeader` methods in `Meetup` model for consistent handling of meetup leadership. - ⚙️ Refactored `MeetupLeaderController` to use new leadership methods, improving reusability and maintainability. - 👮♂️ Added `ValidNpub` validation rule for npub input standardization. - 🧪 Added feature tests for leadership delegation and permissions. - 🖼️ Implemented leader management UI in meetup edit page with flash messaging for actions.
This commit is contained in:
@@ -48,9 +48,7 @@ class MeetupLeaderController extends Controller
|
|||||||
{
|
{
|
||||||
$user = NostrLogin::findOrCreateUser($request->string('npub')->toString());
|
$user = NostrLogin::findOrCreateUser($request->string('npub')->toString());
|
||||||
|
|
||||||
$meetup->users()->syncWithoutDetaching([
|
$meetup->promoteLeader($user);
|
||||||
$user->getKey() => ['is_leader' => true],
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json(['data' => $this->leaders($meetup)], HttpResponse::HTTP_CREATED);
|
return response()->json(['data' => $this->leaders($meetup)], HttpResponse::HTTP_CREATED);
|
||||||
}
|
}
|
||||||
@@ -69,7 +67,7 @@ class MeetupLeaderController extends Controller
|
|||||||
|
|
||||||
abort_if($user->getKey() === $meetup->created_by, HttpResponse::HTTP_FORBIDDEN, __('Der Ersteller des Meetups kann nicht entzogen werden.'));
|
abort_if($user->getKey() === $meetup->created_by, HttpResponse::HTTP_FORBIDDEN, __('Der Ersteller des Meetups kann nicht entzogen werden.'));
|
||||||
|
|
||||||
$meetup->users()->updateExistingPivot($user->getKey(), ['is_leader' => false]);
|
$meetup->demoteLeader($user);
|
||||||
|
|
||||||
return response()->json(['data' => $this->leaders($meetup)]);
|
return response()->json(['data' => $this->leaders($meetup)]);
|
||||||
}
|
}
|
||||||
@@ -81,9 +79,7 @@ class MeetupLeaderController extends Controller
|
|||||||
*/
|
*/
|
||||||
private function leaders(Meetup $meetup): array
|
private function leaders(Meetup $meetup): array
|
||||||
{
|
{
|
||||||
return $meetup->users()
|
return $meetup->leaders()
|
||||||
->wherePivot('is_leader', true)
|
|
||||||
->get()
|
|
||||||
->map(fn (User $user): array => [
|
->map(fn (User $user): array => [
|
||||||
'id' => $user->getKey(),
|
'id' => $user->getKey(),
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
@@ -91,8 +87,6 @@ class MeetupLeaderController extends Controller
|
|||||||
'avatar' => $user->profile_photo_url,
|
'avatar' => $user->profile_photo_url,
|
||||||
'is_creator' => $user->getKey() === $meetup->created_by,
|
'is_creator' => $user->getKey() === $meetup->created_by,
|
||||||
])
|
])
|
||||||
->sortByDesc('is_creator')
|
|
||||||
->values()
|
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests\Api;
|
namespace App\Http\Requests\Api;
|
||||||
|
|
||||||
use Closure;
|
use App\Rules\ValidNpub;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use swentel\nostr\Key\Key as NostrKey;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setzt einen weiteren Leader für ein Meetup per Nostr-npub ein. Nur ein
|
* Setzt einen weiteren Leader für ein Meetup per Nostr-npub ein. Nur ein
|
||||||
@@ -24,18 +23,7 @@ class StoreMeetupLeaderRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'npub' => [
|
'npub' => ['required', 'string', new ValidNpub],
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'starts_with:npub1',
|
|
||||||
function (string $attribute, mixed $value, Closure $fail): void {
|
|
||||||
try {
|
|
||||||
(new NostrKey)->convertToHex((string) $value);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
$fail(__('Das ist kein gültiger npub.'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cookie;
|
use Illuminate\Support\Facades\Cookie;
|
||||||
use Illuminate\Support\Facades\Date;
|
use Illuminate\Support\Facades\Date;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -198,6 +199,45 @@ class Meetup extends Model implements HasMedia
|
|||||||
->exists();
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktuelle Leader dieses Meetups (meetup_user.is_leader = true), Ersteller
|
||||||
|
* zuerst. Gemeinsame Quelle für REST-API (MeetupLeaderController) und das
|
||||||
|
* Portal-Frontend (meetups.edit).
|
||||||
|
*
|
||||||
|
* @return Collection<int, User>
|
||||||
|
*/
|
||||||
|
public function leaders(): Collection
|
||||||
|
{
|
||||||
|
return $this->users()
|
||||||
|
->wherePivot('is_leader', true)
|
||||||
|
->get()
|
||||||
|
->sortByDesc(fn (User $user): bool => $user->getKey() === $this->created_by)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nutzer zum Leader befördern (meetup_user.is_leader = true). Idempotent.
|
||||||
|
* Geteilt von REST-Controller, MCP-Tools und Portal-Frontend.
|
||||||
|
*/
|
||||||
|
public function promoteLeader(User $user): void
|
||||||
|
{
|
||||||
|
$this->users()->syncWithoutDetaching([$user->getKey() => ['is_leader' => true]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nutzer die Leader-Rolle entziehen (Demote; bleibt Mitglied). Der Ersteller
|
||||||
|
* des Meetups ist geschützt und wird nie entzogen (Domain-Invariante) —
|
||||||
|
* Aufrufer dürfen zusätzlich mit 403 antworten.
|
||||||
|
*/
|
||||||
|
public function demoteLeader(User $user): void
|
||||||
|
{
|
||||||
|
if ($user->getKey() === $this->created_by) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->users()->updateExistingPivot($user->getKey(), ['is_leader' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Den Nutzer als Mitglied (nicht Leader) zu „Meine Meetups" hinzufügen.
|
* Den Nutzer als Mitglied (nicht Leader) zu „Meine Meetups" hinzufügen.
|
||||||
* Idempotent: ein bereits hinzugefügter Nutzer bleibt unverändert. Gibt
|
* Idempotent: ein bereits hinzugefügter Nutzer bleibt unverändert. Gibt
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use swentel\nostr\Key\Key as NostrKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert einen Nostr-npub (bech32-kodierter öffentlicher Schlüssel).
|
||||||
|
* Geteilt von der REST-API (StoreMeetupLeaderRequest) und der Leader-
|
||||||
|
* Verwaltung im Portal-Frontend, damit beide Wege dieselbe Prüfung nutzen.
|
||||||
|
*/
|
||||||
|
class ValidNpub implements ValidationRule
|
||||||
|
{
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
if (! is_string($value) || ! str_starts_with($value, 'npub1')) {
|
||||||
|
$fail(__('Das ist kein gültiger npub.'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new NostrKey)->convertToHex($value);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$fail(__('Das ist kein gültiger npub.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,14 @@ use App\Attributes\SeoDataAttribute;
|
|||||||
use App\Models\City;
|
use App\Models\City;
|
||||||
use App\Models\Country;
|
use App\Models\Country;
|
||||||
use App\Models\Meetup;
|
use App\Models\Meetup;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Rules\ValidNpub;
|
||||||
|
use App\Support\NostrLogin;
|
||||||
use App\Traits\SeoTrait;
|
use App\Traits\SeoTrait;
|
||||||
|
use Flux\Flux;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -14,9 +19,10 @@ use Livewire\WithFileUploads;
|
|||||||
|
|
||||||
new
|
new
|
||||||
#[SeoDataAttribute(key: 'meetups_edit')]
|
#[SeoDataAttribute(key: 'meetups_edit')]
|
||||||
class extends Component {
|
class extends Component
|
||||||
use WithFileUploads;
|
{
|
||||||
use SeoTrait;
|
use SeoTrait;
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
#[Validate('image|mimes:jpeg,png,webp,avif|max:5120|dimensions:max_width=4000,max_height=4000')]
|
#[Validate('image|mimes:jpeg,png,webp,avif|max:5120|dimensions:max_width=4000,max_height=4000')]
|
||||||
public $logo;
|
public $logo;
|
||||||
@@ -25,23 +31,35 @@ class extends Component {
|
|||||||
|
|
||||||
// Basic Information
|
// Basic Information
|
||||||
public string $name = '';
|
public string $name = '';
|
||||||
|
|
||||||
public ?int $city_id = null;
|
public ?int $city_id = null;
|
||||||
|
|
||||||
public string $slug = '';
|
public string $slug = '';
|
||||||
|
|
||||||
public ?string $intro = null;
|
public ?string $intro = null;
|
||||||
|
|
||||||
// Links and Social Media
|
// Links and Social Media
|
||||||
public ?string $telegram_link = null;
|
public ?string $telegram_link = null;
|
||||||
|
|
||||||
public ?string $webpage = null;
|
public ?string $webpage = null;
|
||||||
|
|
||||||
public ?string $twitter_username = null;
|
public ?string $twitter_username = null;
|
||||||
|
|
||||||
public ?string $matrix_group = null;
|
public ?string $matrix_group = null;
|
||||||
|
|
||||||
public ?string $nostr = null;
|
public ?string $nostr = null;
|
||||||
|
|
||||||
public ?string $nostr_status = null;
|
public ?string $nostr_status = null;
|
||||||
|
|
||||||
public ?string $simplex = null;
|
public ?string $simplex = null;
|
||||||
|
|
||||||
public ?string $signal = null;
|
public ?string $signal = null;
|
||||||
|
|
||||||
// Additional Information
|
// Additional Information
|
||||||
public ?string $community = null;
|
public ?string $community = null;
|
||||||
|
|
||||||
public ?string $github_data = null;
|
public ?string $github_data = null;
|
||||||
|
|
||||||
public bool $visible_on_map = false;
|
public bool $visible_on_map = false;
|
||||||
|
|
||||||
// System fields (read-only) - locked to prevent client-side tampering
|
// System fields (read-only) - locked to prevent client-side tampering
|
||||||
@@ -54,10 +72,16 @@ class extends Component {
|
|||||||
#[Locked]
|
#[Locked]
|
||||||
public ?string $updated_at = null;
|
public ?string $updated_at = null;
|
||||||
|
|
||||||
|
// Leader management (Delegation): npub des einzusetzenden Leaders.
|
||||||
|
public string $leaderNpub = '';
|
||||||
|
|
||||||
// New City Modal
|
// New City Modal
|
||||||
public string $newCityName = '';
|
public string $newCityName = '';
|
||||||
|
|
||||||
public ?int $newCityCountryId = null;
|
public ?int $newCityCountryId = null;
|
||||||
|
|
||||||
public ?float $newCityLatitude = null;
|
public ?float $newCityLatitude = null;
|
||||||
|
|
||||||
public ?float $newCityLongitude = null;
|
public ?float $newCityLongitude = null;
|
||||||
|
|
||||||
public function createCity(): void
|
public function createCity(): void
|
||||||
@@ -81,7 +105,7 @@ class extends Component {
|
|||||||
$this->city_id = $city->id;
|
$this->city_id = $city->id;
|
||||||
$this->reset(['newCityName', 'newCityCountryId', 'newCityLatitude', 'newCityLongitude']);
|
$this->reset(['newCityName', 'newCityCountryId', 'newCityLatitude', 'newCityLongitude']);
|
||||||
|
|
||||||
\Flux\Flux::modal('add-city')->close();
|
Flux::modal('add-city')->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,6 +120,49 @@ class extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktuelle Leader dieses Meetups (meetup_user.is_leader = true), Ersteller
|
||||||
|
* zuerst. Treibt die Leader-Verwaltungsliste auf der Bearbeiten-Seite.
|
||||||
|
*/
|
||||||
|
#[Computed]
|
||||||
|
public function leaders()
|
||||||
|
{
|
||||||
|
return $this->meetup->leaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einen weiteren Leader per npub einsetzen. Nur Leader/Ersteller dürfen das
|
||||||
|
* (manageLeaders). Existiert noch kein Account für den npub, wird er angelegt
|
||||||
|
* (greift beim ersten Login). Idempotent: ein bereits gesetzter Leader bleibt.
|
||||||
|
*/
|
||||||
|
public function addLeader(): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manageLeaders', $this->meetup), 403);
|
||||||
|
|
||||||
|
$validated = $this->validate(['leaderNpub' => ['required', 'string', new ValidNpub]]);
|
||||||
|
|
||||||
|
$this->meetup->promoteLeader(NostrLogin::findOrCreateUser($validated['leaderNpub']));
|
||||||
|
|
||||||
|
$this->leaderNpub = '';
|
||||||
|
unset($this->leaders);
|
||||||
|
session()->flash('leaderStatus', __('Leader eingesetzt.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einem Nutzer die Leader-Rolle entziehen (Demote; bleibt Mitglied). Der
|
||||||
|
* Ersteller des Meetups ist geschützt und kann nie entzogen werden.
|
||||||
|
*/
|
||||||
|
public function removeLeader(int $userId): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()?->can('manageLeaders', $this->meetup), 403);
|
||||||
|
abort_if($userId === $this->meetup->created_by, 403, __('Der Ersteller des Meetups kann nicht entzogen werden.'));
|
||||||
|
|
||||||
|
$this->meetup->demoteLeader(User::findOrFail($userId));
|
||||||
|
|
||||||
|
unset($this->leaders);
|
||||||
|
session()->flash('leaderStatus', __('Leader entzogen.'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whitelist the keys allowed inside github_data and coerce types so a
|
* Whitelist the keys allowed inside github_data and coerce types so a
|
||||||
* tampered payload cannot smuggle arbitrary keys into the stored JSON.
|
* tampered payload cannot smuggle arbitrary keys into the stored JSON.
|
||||||
@@ -107,7 +174,7 @@ class extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$decoded = json_decode($raw, true);
|
$decoded = json_decode($raw, true);
|
||||||
if (!is_array($decoded)) {
|
if (! is_array($decoded)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,6 +506,64 @@ class extends Component {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Leader-Verwaltung (Delegation) -->
|
||||||
|
<flux:fieldset class="space-y-6 mt-10 pt-10 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<flux:legend>{{ __('Leader verwalten') }}</flux:legend>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-amber-300 bg-amber-50 p-4 space-y-2 dark:border-amber-500/40 dark:bg-amber-500/10">
|
||||||
|
<div class="flex items-center gap-2 font-semibold text-amber-800 dark:text-amber-300">
|
||||||
|
<flux:icon name="information-circle" class="size-5"/>
|
||||||
|
{{ __('Was ist ein Leader?') }}
|
||||||
|
</div>
|
||||||
|
<flux:text>
|
||||||
|
{{ __('Leader dürfen dieses Meetup bearbeiten und selbst weitere Leader einsetzen. Setze nur Personen ein, denen du vertraust.') }}
|
||||||
|
</flux:text>
|
||||||
|
<flux:text>
|
||||||
|
{{ __('Frag die Person nach ihrem Nostr-Schlüssel (npub). Sie findet ihn in ihrer Nostr-App oder in ihrem Portal-Profil — er beginnt mit „npub1…“.') }}
|
||||||
|
</flux:text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="addLeader" class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||||
|
<flux:field class="flex-1">
|
||||||
|
<flux:label>{{ __('npub der Person') }}</flux:label>
|
||||||
|
<flux:input wire:model="leaderNpub" placeholder="npub1…"/>
|
||||||
|
<flux:error name="leaderNpub"/>
|
||||||
|
</flux:field>
|
||||||
|
<flux:button class="cursor-pointer" type="submit" variant="primary" icon="user-plus">
|
||||||
|
{{ __('Als Leader einsetzen') }}
|
||||||
|
</flux:button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if (session('leaderStatus'))
|
||||||
|
<flux:text class="font-medium text-green-600 dark:text-green-400">{{ session('leaderStatus') }}</flux:text>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<flux:text class="font-medium">{{ __('Aktuelle Leader') }}</flux:text>
|
||||||
|
@foreach ($this->leaders as $leader)
|
||||||
|
<div class="flex items-center gap-3 rounded-xl border border-zinc-200 p-3 dark:border-zinc-700"
|
||||||
|
wire:key="leader-{{ $leader->id }}">
|
||||||
|
<flux:avatar size="sm" circle :name="$leader->name" src="{{ $leader->profile_photo_url }}"/>
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span class="truncate font-semibold">{{ $leader->name }}</span>
|
||||||
|
@if ($leader->nostr)
|
||||||
|
<span class="truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">{{ $leader->nostr }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if ($leader->id === $meetup->created_by)
|
||||||
|
<flux:badge color="amber" icon="star">{{ __('Ersteller') }}</flux:badge>
|
||||||
|
@else
|
||||||
|
<flux:button class="cursor-pointer" size="sm" variant="ghost" icon="user-minus"
|
||||||
|
wire:click="removeLeader({{ $leader->id }})"
|
||||||
|
wire:confirm="{{ __(':name die Leader-Rolle entziehen? Die Person bleibt Mitglied, kann das Meetup aber nicht mehr bearbeiten.', ['name' => $leader->name]) }}">
|
||||||
|
{{ __('Entziehen') }}
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</flux:fieldset>
|
||||||
|
|
||||||
<!-- Add City Modal -->
|
<!-- Add City Modal -->
|
||||||
<flux:modal name="add-city" variant="flyout" wire:key="add-city-modal">
|
<flux:modal name="add-city" variant="flyout" wire:key="add-city-modal">
|
||||||
<form wire:submit="createCity" class="space-y-6">
|
<form wire:submit="createCity" class="space-y-6">
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\City;
|
||||||
|
use App\Models\Country;
|
||||||
|
use App\Models\Meetup;
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use swentel\nostr\Key\Key as NostrKey;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$country = Country::factory()->create(['code' => 'de']);
|
||||||
|
$this->city = City::factory()->create(['country_id' => $country->id]);
|
||||||
|
$this->creator = actingAsUser();
|
||||||
|
$this->meetup = Meetup::factory()->create([
|
||||||
|
'city_id' => $this->city->id,
|
||||||
|
'created_by' => $this->creator->id,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function webNpub(string $hex): string
|
||||||
|
{
|
||||||
|
return (new NostrKey)->convertPublicKeyToBech32($hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('appoints a leader by npub from the edit page', function () {
|
||||||
|
$npub = webNpub(str_pad('a', 64, 'a'));
|
||||||
|
|
||||||
|
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
|
||||||
|
->set('leaderNpub', $npub)
|
||||||
|
->call('addLeader')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$newUser = User::where('nostr', $npub)->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('meetup_user', [
|
||||||
|
'meetup_id' => $this->meetup->id,
|
||||||
|
'user_id' => $newUser->id,
|
||||||
|
'is_leader' => true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid npub on the edit page', function () {
|
||||||
|
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
|
||||||
|
->set('leaderNpub', 'not-an-npub')
|
||||||
|
->call('addLeader')
|
||||||
|
->assertHasErrors('leaderNpub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('demotes a leader but keeps the membership', function () {
|
||||||
|
$leader = User::factory()->create();
|
||||||
|
$this->meetup->users()->syncWithoutDetaching([$leader->id => ['is_leader' => true]]);
|
||||||
|
|
||||||
|
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
|
||||||
|
->call('removeLeader', $leader->id)
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('meetup_user', [
|
||||||
|
'meetup_id' => $this->meetup->id,
|
||||||
|
'user_id' => $leader->id,
|
||||||
|
'is_leader' => false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lets the creator be demoted', function () {
|
||||||
|
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
|
||||||
|
->call('removeLeader', $this->creator->id)
|
||||||
|
->assertStatus(403);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('meetup_user', [
|
||||||
|
'meetup_id' => $this->meetup->id,
|
||||||
|
'user_id' => $this->creator->id,
|
||||||
|
'is_leader' => true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forbids a plain member from managing leaders via the edit page', function () {
|
||||||
|
$member = User::factory()->create();
|
||||||
|
$this->meetup->addMember($member); // is_leader = false
|
||||||
|
|
||||||
|
$this->actingAs($member);
|
||||||
|
|
||||||
|
// Nicht-Leader kommen schon nicht auf die Seite (authorizeAccess).
|
||||||
|
Livewire::test('meetups.edit', ['meetup' => $this->meetup])
|
||||||
|
->assertStatus(403);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user