feat: added settings in room to kick

This commit is contained in:
2026-09-06 16:06:12 +02:00
parent 1e0f5cf143
commit 817b1ce1cb
10 changed files with 439 additions and 107 deletions
@@ -5,7 +5,6 @@ namespace App\Http\Controllers;
use App\Http\Requests\KickRoomPlayerRequest; use App\Http\Requests\KickRoomPlayerRequest;
use App\Models\Player; use App\Models\Player;
use App\Models\Room; use App\Models\Room;
use App\Models\User;
use App\Services\Rooms\RoomParticipationService; use App\Services\Rooms\RoomParticipationService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Inertia\Inertia; use Inertia\Inertia;
@@ -18,11 +17,12 @@ class RoomPlayerKickController extends Controller
public function __invoke(KickRoomPlayerRequest $request, Room $room, Player $player): RedirectResponse public function __invoke(KickRoomPlayerRequest $request, Room $room, Player $player): RedirectResponse
{ {
$user = $request->user(); $this->roomParticipation->kick(
$room,
abort_unless($user instanceof User, 403); $player,
$request->user(),
$this->roomParticipation->kick($room, $player, $user); $request->currentPlayer($room),
);
Inertia::flash('toast', [ Inertia::flash('toast', [
'type' => 'success', 'type' => 'success',
+34 -6
View File
@@ -14,13 +14,41 @@ class KickRoomPlayerRequest extends FormRequest
public function authorize(): bool public function authorize(): bool
{ {
$room = $this->route('room'); $room = $this->route('room');
$player = $this->route('player'); $targetPlayer = $this->route('player');
return $room instanceof Room if (
&& $player instanceof Player ! $room instanceof Room
&& $this->user()?->id === $room->owner_id || ! $targetPlayer instanceof Player
&& $player->room_id === $room->id || $targetPlayer->room_id !== $room->id
&& $player->user_id !== $room->owner_id; || $targetPlayer->is_moderator
|| ($room->owner_id !== null && $targetPlayer->user_id === $room->owner_id)
) {
return false;
}
$user = $this->user();
if ($user !== null && $user->id === $room->owner_id) {
return true;
}
$currentPlayer = $this->currentPlayer($room);
return ! $room->isClosed()
&& $currentPlayer !== null
&& $currentPlayer->id !== $targetPlayer->id
&& ($currentPlayer->is_moderator || $room->allow_participants_to_manage_rounds);
}
public function currentPlayer(Room $room): ?Player
{
$playerId = $this->session()->get("room_players.{$room->id}");
if (! is_numeric($playerId)) {
return null;
}
return $room->activePlayers()->find((int) $playerId);
} }
/** /**
@@ -180,20 +180,41 @@ class RoomParticipationService
return $player; return $player;
} }
public function kick(Room $room, Player $player, User $user): void public function kick(
{ Room $room,
$stateVersion = DB::transaction(function () use ($room, $player, $user): ?int { Player $player,
?User $user,
?Player $currentPlayer,
): void {
$stateVersion = DB::transaction(function () use ($room, $player, $user, $currentPlayer): ?int {
$lockedRoom = Room::query() $lockedRoom = Room::query()
->lockForUpdate() ->lockForUpdate()
->findOrFail($room->id); ->findOrFail($room->id);
abort_unless($lockedRoom->owner_id === $user->id, 403); $lockedCurrentPlayer = $currentPlayer === null
? null
: $lockedRoom->activePlayers()
->lockForUpdate()
->find($currentPlayer->id);
$isOwner = $user !== null && $lockedRoom->owner_id === $user->id;
$participantCanManage = ! $lockedRoom->isClosed()
&& $lockedCurrentPlayer !== null
&& ($lockedCurrentPlayer->is_moderator
|| $lockedRoom->allow_participants_to_manage_rounds);
abort_unless($isOwner || $participantCanManage, 403);
$lockedPlayer = $lockedRoom->players() $lockedPlayer = $lockedRoom->players()
->lockForUpdate() ->lockForUpdate()
->findOrFail($player->id); ->findOrFail($player->id);
abort_if($lockedPlayer->user_id === $lockedRoom->owner_id, 403); abort_if(
$lockedPlayer->is_moderator
|| ($lockedRoom->owner_id !== null
&& $lockedPlayer->user_id === $lockedRoom->owner_id)
|| $lockedCurrentPlayer?->id === $lockedPlayer->id,
403,
);
if ($lockedPlayer->inactive_at !== null) { if ($lockedPlayer->inactive_at !== null) {
return null; return null;
@@ -67,7 +67,13 @@ const {
:title="realtimeRoom.title" :title="realtimeRoom.title"
:join_code="realtimeRoom.join_code" :join_code="realtimeRoom.join_code"
:is_favorite="realtimeRoom.is_favorite" :is_favorite="realtimeRoom.is_favorite"
:players="realtimeRoom.players"
:can-favorite="canFavorite" :can-favorite="canFavorite"
:can-manage-participants="
realtimeCurrentPlayer.is_moderator ||
realtimeRoom.allow_participants_to_manage_rounds
"
:current-player-id="realtimeCurrentPlayer.id"
:embedded="embedded" :embedded="embedded"
:player-name="realtimeCurrentPlayer.name" :player-name="realtimeCurrentPlayer.name"
v-model:presenter-mode="presenterMode" v-model:presenter-mode="presenterMode"
+197 -3
View File
@@ -1,17 +1,65 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link, useForm } from '@inertiajs/vue3';
import { Check, Copy, LogOut } from '@lucide/vue'; import { Check, Copy, LogOut, Settings, UserMinus, Users } from '@lucide/vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { logout as embeddedLogout } from '@/routes/embedded'; import { logout as embeddedLogout } from '@/routes/embedded';
import { favorite } from '@/routes/room'; import { favorite } from '@/routes/room';
import type { RoomHeaderEmits, RoomHeaderProps } from '@/types/room'; import { destroy as kickRoomPlayer } from '@/routes/room/player';
import type {
RoomHeaderEmits,
RoomHeaderProps,
RoomPlayer,
} from '@/types/room';
const props = defineProps<RoomHeaderProps>(); const props = defineProps<RoomHeaderProps>();
const emit = defineEmits<RoomHeaderEmits>(); const emit = defineEmits<RoomHeaderEmits>();
const copied = ref(false); const copied = ref(false);
const participantsDialogOpen = ref(false);
const selectedPlayer = ref<RoomPlayer | null>(null);
const kickForm = useForm({});
function kickPlayer(): void {
if (selectedPlayer.value === null || kickForm.processing) {
return;
}
kickForm.delete(
kickRoomPlayer({
room: props.id,
player: selectedPlayer.value.id,
}).url,
{
preserveScroll: true,
onSuccess: () => {
selectedPlayer.value = null;
},
},
);
}
function setKickDialogOpen(open: boolean): void {
if (!open && !kickForm.processing) {
selectedPlayer.value = null;
}
}
async function copyInviteLink(): Promise<void> { async function copyInviteLink(): Promise<void> {
const inviteUrl = new URL('/room/join', window.location.origin); const inviteUrl = new URL('/room/join', window.location.origin);
@@ -26,6 +74,7 @@ async function copyInviteLink(): Promise<void> {
</script> </script>
<template> <template>
<div class="contents">
<header <header
class="mx-auto flex w-full max-w-6xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between" class="mx-auto flex w-full max-w-6xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
> >
@@ -67,6 +116,34 @@ async function copyInviteLink(): Promise<void> {
Abmelden Abmelden
</Link> </Link>
<div class="flex items-center gap-2">
<DropdownMenu v-if="canManageParticipants">
<DropdownMenuTrigger :as-child="true">
<button
type="button"
class="inline-flex size-11 items-center justify-center rounded-xl border border-slate-700 bg-slate-800 text-slate-300 transition hover:bg-slate-700 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300"
aria-label="Raumeinstellungen öffnen"
>
<Settings class="size-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
class="w-52 border-slate-700 bg-slate-800 text-slate-100"
>
<DropdownMenuLabel class="text-slate-400">
Einstellungen
</DropdownMenuLabel>
<DropdownMenuItem
class="focus:bg-slate-700 focus:text-white"
@select="participantsDialogOpen = true"
>
<Users class="size-4" />
Teilnehmer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button <button
type="button" type="button"
class="flex w-fit items-center gap-3 rounded-xl border border-slate-700 bg-slate-800 px-4 py-2.5 text-left transition hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300" class="flex w-fit items-center gap-3 rounded-xl border border-slate-700 bg-slate-800 px-4 py-2.5 text-left transition hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300"
@@ -85,6 +162,7 @@ async function copyInviteLink(): Promise<void> {
<Check v-if="copied" class="size-5 text-emerald-300" /> <Check v-if="copied" class="size-5 text-emerald-300" />
<Copy v-else class="size-5 text-slate-300" /> <Copy v-else class="size-5 text-slate-300" />
</button> </button>
</div>
<label <label
for="presenter-mode" for="presenter-mode"
@@ -101,4 +179,120 @@ async function copyInviteLink(): Promise<void> {
</label> </label>
</div> </div>
</header> </header>
<Dialog v-model:open="participantsDialogOpen">
<DialogContent
class="max-h-[calc(100dvh-2rem)] overflow-hidden border-slate-700 bg-slate-800 text-slate-100 sm:max-w-xl"
overlay-class="bg-black/70 backdrop-blur-sm"
>
<DialogHeader>
<DialogTitle>Teilnehmer</DialogTitle>
<DialogDescription class="text-slate-300">
Verwalte die aktiven Teilnehmer in diesem Raum.
</DialogDescription>
</DialogHeader>
<ul
v-if="players.length > 0"
class="flex max-h-[min(60dvh,32rem)] flex-col gap-2 overflow-y-auto pr-1"
>
<li
v-for="player in players"
:key="player.id"
class="flex items-center justify-between gap-3 rounded-md bg-slate-900/60 px-3 py-2"
>
<span class="min-w-0">
<span
class="block truncate text-sm"
:class="
player.is_moderator
? 'text-amber-300'
: 'text-slate-100'
"
>
{{ player.name }}
</span>
</span>
<span
v-if="
player.id === currentPlayerId ||
player.is_moderator
"
class="shrink-0 text-xs text-slate-400"
>
{{
player.id === currentPlayerId
? player.is_moderator
? 'Du · Admin'
: 'Du'
: 'Admin'
}}
</span>
<button
v-else
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-semibold text-rose-300 transition hover:bg-rose-500/20 hover:text-rose-200"
@click="selectedPlayer = player"
>
<UserMinus class="size-3.5" />
Kicken
</button>
</li>
</ul>
<p v-else class="text-sm text-slate-400">
Aktuell sind keine Teilnehmer im Raum.
</p>
<DialogFooter>
<button
type="button"
class="rounded-md border border-slate-600 px-4 py-2 text-sm font-semibold text-slate-200 transition hover:bg-slate-700"
@click="participantsDialogOpen = false"
>
Schließen
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
:open="selectedPlayer !== null"
@update:open="setKickDialogOpen"
>
<DialogContent
class="border-slate-700 bg-slate-800 text-slate-100 sm:max-w-md"
overlay-class="bg-black/70 backdrop-blur-sm"
>
<DialogHeader>
<DialogTitle>Teilnehmer wirklich kicken?</DialogTitle>
<DialogDescription class="text-slate-300">
{{ selectedPlayer?.name }} wird aus „{{
title ?? 'Scrum Poker'
}}“ entfernt und zur Startseite weitergeleitet.
</DialogDescription>
</DialogHeader>
<DialogFooter class="gap-6 space-x-4 sm:gap-0">
<button
type="button"
class="rounded-md border border-slate-600 px-4 py-2 text-sm font-semibold text-slate-200 transition hover:bg-slate-700"
:disabled="kickForm.processing"
@click="selectedPlayer = null"
>
Abbrechen
</button>
<button
type="button"
class="rounded-md bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-500 disabled:opacity-50"
:disabled="kickForm.processing"
@click="kickPlayer"
>
{{ kickForm.processing ? 'Wird entfernt …' : 'Kicken' }}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template> </template>
@@ -16,19 +16,26 @@ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) })
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), { const props = withDefaults(
defineProps<DialogContentProps & {
class?: HTMLAttributes["class"]
overlayClass?: HTMLAttributes["class"]
showCloseButton?: boolean
}>(),
{
showCloseButton: true, showCloseButton: true,
}) },
)
const emits = defineEmits<DialogContentEmits>() const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class") const delegatedProps = reactiveOmit(props, "class", "overlayClass", "showCloseButton")
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
<template> <template>
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay :class="props.overlayClass" />
<DialogContent <DialogContent
data-slot="dialog-content" data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }" v-bind="{ ...$attrs, ...forwarded }"
+8 -6
View File
@@ -48,9 +48,10 @@ function submit(): void {
class="flex flex-col gap-6 rounded-xl border border-slate-700 bg-slate-800 p-6" class="flex flex-col gap-6 rounded-xl border border-slate-700 bg-slate-800 p-6"
@submit.prevent="submit" @submit.prevent="submit"
> >
<label class="flex cursor-pointer items-start gap-4"> <label class="flex cursor-pointer items-center gap-4">
<Checkbox <Checkbox
:model-value="form.allow_participants_to_manage_rounds" :model-value="form.allow_participants_to_manage_rounds"
class="border-white"
@update:model-value=" @update:model-value="
form.allow_participants_to_manage_rounds = form.allow_participants_to_manage_rounds =
$event === true $event === true
@@ -58,12 +59,12 @@ function submit(): void {
/> />
<span> <span>
<strong class="block" <strong class="block"
>Alle dürfen Runden steuern</strong >Alle dürfen den Raum verwalten</strong
> >
<span class="mt-1 block text-sm text-slate-400"> <span class="mt-1 block text-sm text-slate-400">
Alle aktiven Teilnehmer, auch Gäste, dürfen Karten Alle aktiven Teilnehmer, auch Gäste, dürfen Runden
aufdecken, Votes zurücksetzen und die nächste Runde steuern und andere Teilnehmer aus dem Raum
starten. entfernen.
</span> </span>
</span> </span>
</label> </label>
@@ -71,9 +72,10 @@ function submit(): void {
:message="form.errors.allow_participants_to_manage_rounds" :message="form.errors.allow_participants_to_manage_rounds"
/> />
<label class="flex cursor-pointer items-start gap-4"> <label class="flex cursor-pointer items-center gap-4">
<Checkbox <Checkbox
:model-value="form.is_persistent" :model-value="form.is_persistent"
class="border-white"
@update:model-value=" @update:model-value="
form.is_persistent = $event === true form.is_persistent = $event === true
" "
+3 -1
View File
@@ -110,9 +110,11 @@ export type RoomRetentionNoticeProps = {
export type RoomHeaderProps = Pick< export type RoomHeaderProps = Pick<
PokerRoom, PokerRoom,
'id' | 'title' | 'join_code' | 'is_favorite' 'id' | 'title' | 'join_code' | 'is_favorite' | 'players'
> & { > & {
canFavorite: boolean; canFavorite: boolean;
canManageParticipants: boolean;
currentPlayerId: number;
presenterMode: boolean; presenterMode: boolean;
embedded: boolean; embedded: boolean;
playerName: string; playerName: string;
+2 -1
View File
@@ -45,9 +45,10 @@ Route::middleware(['auth', 'verified'])->group(function () {
->name('room.destroy'); ->name('room.destroy');
Route::post('/rooms/{room}/favorite', FavoriteRoomController::class) Route::post('/rooms/{room}/favorite', FavoriteRoomController::class)
->name('room.favorite'); ->name('room.favorite');
});
Route::delete('/rooms/{room}/players/{player}', RoomPlayerKickController::class) Route::delete('/rooms/{room}/players/{player}', RoomPlayerKickController::class)
->name('room.player.destroy'); ->name('room.player.destroy');
});
Route::middleware('room.player')->group(function () { Route::middleware('room.player')->group(function () {
Route::get('/rooms/{room}', [RoomController::class, 'show'])->name('room.show'); Route::get('/rooms/{room}', [RoomController::class, 'show'])->name('room.show');
+73 -2
View File
@@ -104,17 +104,34 @@ test('the room owner can kick an active participant', function () {
Event::assertDispatchedTimes(RoomStateChanged::class, 1); Event::assertDispatchedTimes(RoomStateChanged::class, 1);
}); });
test('a participant cannot kick another player', function () { test('the room moderator can kick an active participant without shared management', function () {
$owner = User::factory()->create();
$room = createRoomForPlayerKick($owner, ['owner_id' => null]);
$moderator = addPlayerToKickRoom($room, 'Moderator', null, true);
$target = addPlayerToKickRoom($room, 'Anna');
Event::fake([RoomStateChanged::class]);
$this->withSession(["room_players.{$room->id}" => $moderator->id])
->delete(route('room.player.destroy', [$room, $target]))
->assertRedirect();
expect($target->fresh()->inactive_at)->not->toBeNull();
Event::assertDispatchedTimes(RoomStateChanged::class, 1);
});
test('a participant cannot kick another player when participant management is disabled', function () {
$owner = User::factory()->create(); $owner = User::factory()->create();
$participantUser = User::factory()->create(); $participantUser = User::factory()->create();
$room = createRoomForPlayerKick($owner); $room = createRoomForPlayerKick($owner);
addPlayerToKickRoom($room, 'Moderator', $owner, true); addPlayerToKickRoom($room, 'Moderator', $owner, true);
addPlayerToKickRoom($room, 'Anna', $participantUser); $participant = addPlayerToKickRoom($room, 'Anna', $participantUser);
$target = addPlayerToKickRoom($room, 'Ben'); $target = addPlayerToKickRoom($room, 'Ben');
Event::fake([RoomStateChanged::class]); Event::fake([RoomStateChanged::class]);
$this->actingAs($participantUser) $this->actingAs($participantUser)
->withSession(["room_players.{$room->id}" => $participant->id])
->delete(route('room.player.destroy', [$room, $target])) ->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden(); ->assertForbidden();
@@ -122,6 +139,60 @@ test('a participant cannot kick another player', function () {
Event::assertNotDispatched(RoomStateChanged::class); Event::assertNotDispatched(RoomStateChanged::class);
}); });
test('a participant can kick another player when participant management is enabled', function () {
$owner = User::factory()->create();
$room = createRoomForPlayerKick($owner, [
'allow_participants_to_manage_rounds' => true,
]);
addPlayerToKickRoom($room, 'Moderator', $owner, true);
$participant = addPlayerToKickRoom($room, 'Anna');
$target = addPlayerToKickRoom($room, 'Ben');
Event::fake([RoomStateChanged::class]);
$this->withSession(["room_players.{$room->id}" => $participant->id])
->delete(route('room.player.destroy', [$room, $target]))
->assertRedirect();
expect($target->fresh()->inactive_at)->not->toBeNull();
Event::assertDispatched(RoomStateChanged::class);
});
test('a participant cannot kick themselves when participant management is enabled', function () {
$owner = User::factory()->create();
$room = createRoomForPlayerKick($owner, [
'allow_participants_to_manage_rounds' => true,
]);
$participant = addPlayerToKickRoom($room, 'Anna');
Event::fake([RoomStateChanged::class]);
$this->withSession(["room_players.{$room->id}" => $participant->id])
->delete(route('room.player.destroy', [$room, $participant]))
->assertForbidden();
expect($participant->fresh()->inactive_at)->toBeNull();
Event::assertNotDispatched(RoomStateChanged::class);
});
test('an anonymous visitor without a room session cannot kick from an ownerless room', function () {
$owner = User::factory()->create();
$room = createRoomForPlayerKick($owner, [
'owner_id' => null,
'allow_participants_to_manage_rounds' => true,
]);
addPlayerToKickRoom($room, 'Moderator', null, true);
$target = addPlayerToKickRoom($room, 'Ben');
Event::fake([RoomStateChanged::class]);
$this->delete(route('room.player.destroy', [$room, $target]))
->assertForbidden();
expect($target->fresh()->inactive_at)->toBeNull();
Event::assertNotDispatched(RoomStateChanged::class);
});
test('the room owner cannot kick their own player', function () { test('the room owner cannot kick their own player', function () {
$owner = User::factory()->create(); $owner = User::factory()->create();
$room = createRoomForPlayerKick($owner); $room = createRoomForPlayerKick($owner);