Compare commits

9 Commits
Author SHA1 Message Date
Twyco b190ff719e audit fix
CI/CD / Publish immutable image (push) Successful in 2m28s
CI/CD / test_and_check_app (push) Successful in 3m43s
2026-09-10 19:16:00 +02:00
Twyco 54d23c3660 Merge pull request #10 from ericasch6/codex/guest-play
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 17s
2026-09-10 17:09:18 +02:00
Twyco 640a2970c3 Handle authenticated room instance takeover
CI/CD / test_and_check_app (push) Failing after 2m7s
CI/CD / Publish immutable image (push) Skipped
2026-09-10 16:33:13 +02:00
Twyco 2ab95cebdf do not need login 2026-09-10 16:33:13 +02:00
Twyco 6ca91f3fe9 Merge pull request #11 from ericasch6/kick-player-from-room-styleFixes-pb-function
CI/CD / test_and_check_app (push) Failing after 1m59s
CI/CD / Publish immutable image (push) Skipped
feat: added settings in room to kick
2026-09-10 16:12:05 +02:00
Twyco 2f6cdcf591 fix app version
CI/CD / test_and_check_app (push) Successful in 3m33s
CI/CD / Publish immutable image (push) Successful in 2m20s
2026-09-07 12:10:56 +02:00
Twyco f44d822fa3 test auto update flux
CI/CD / test_and_check_app (push) Successful in 3m36s
CI/CD / Publish immutable image (push) Successful in 2m16s
2026-09-07 11:58:19 +02:00
Eric 817b1ce1cb feat: added settings in room to kick 2026-09-06 16:06:12 +02:00
Twyco 1e0f5cf143 nginex fix
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (push) Canceled after 3m45s
2026-09-03 15:02:24 +02:00
34 changed files with 967 additions and 189 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
printf -v run_attempt '%03d' "${GITHUB_RUN_ATTEMPT}" printf -v run_attempt '%03d' "${GITHUB_RUN_ATTEMPT}"
tag="main-${GITHUB_RUN_NUMBER}-${run_attempt}-${GITHUB_SHA:0:12}" tag="main-${GITHUB_RUN_NUMBER}-${run_attempt}-${GITHUB_SHA:0:12}"
environment="staging" environment="staging"
vite_app_version="staging-${GITHUB_SHA:0:12}" vite_app_version="main-${GITHUB_SHA:0:12}"
vite_reverb_app_key="1af96b48d2ef78983e4ef281eed3a48b" vite_reverb_app_key="1af96b48d2ef78983e4ef281eed3a48b"
vite_reverb_host="scrum-poker.twyco-staging.de" vite_reverb_host="scrum-poker.twyco-staging.de"
;; ;;
+1
View File
@@ -1,3 +1,4 @@
# Scrumpoker without AD's # Scrumpoker without AD's
trigger workflow trigger workflow
+1
View File
@@ -7,6 +7,7 @@ enum RoomStateChange: string
case PlayerJoined = 'player_joined'; case PlayerJoined = 'player_joined';
case PlayerUpdated = 'player_updated'; case PlayerUpdated = 'player_updated';
case PlayerKicked = 'player_kicked'; case PlayerKicked = 'player_kicked';
case PlayerInstanceReplaced = 'player_instance_replaced';
case VoteChanged = 'vote_changed'; case VoteChanged = 'vote_changed';
case CardsRevealed = 'cards_revealed'; case CardsRevealed = 'cards_revealed';
case VotesReset = 'votes_reset'; case VotesReset = 'votes_reset';
+6 -1
View File
@@ -22,6 +22,7 @@ class RoomStateChanged implements ShouldBroadcastNow
public readonly RoomStateChange $change, public readonly RoomStateChange $change,
public readonly ?array $state = null, public readonly ?array $state = null,
public readonly ?int $affectedPlayerId = null, public readonly ?int $affectedPlayerId = null,
public readonly ?string $affectedInstanceId = null,
) {} ) {}
public function broadcastOn(): Channel public function broadcastOn(): Channel
@@ -35,7 +36,7 @@ class RoomStateChanged implements ShouldBroadcastNow
} }
/** /**
* @return array{version: int, change: string, state: array<string, mixed>|null, affected_player_id?: int} * @return array{version: int, change: string, state: array<string, mixed>|null, affected_player_id?: int, affected_instance_id?: string}
*/ */
public function broadcastWith(): array public function broadcastWith(): array
{ {
@@ -49,6 +50,10 @@ class RoomStateChanged implements ShouldBroadcastNow
$payload['affected_player_id'] = $this->affectedPlayerId; $payload['affected_player_id'] = $this->affectedPlayerId;
} }
if ($this->affectedInstanceId !== null) {
$payload['affected_instance_id'] = $this->affectedInstanceId;
}
return $payload; return $payload;
} }
} }
@@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Room;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -15,11 +16,27 @@ class EmbeddedLogoutController extends Controller
{ {
$joinCode = $request->string('join_code')->trim()->upper()->toString(); $joinCode = $request->string('join_code')->trim()->upper()->toString();
$playerName = $request->string('player_name')->trim()->toString(); $playerName = $request->string('player_name')->trim()->toString();
$room = Room::query()->where('join_code', $joinCode)->first();
$playerId = null;
if ($room !== null) {
$sessionPlayerId = $request->session()->get("room_players.{$room->id}");
if (is_numeric($sessionPlayerId)) {
$playerId = $room->players()
->whereKey((int) $sessionPlayerId)
->value('id');
}
}
Auth::guard('web')->logout(); Auth::guard('web')->logout();
$request->session()->invalidate(); $request->session()->invalidate();
$request->session()->regenerateToken(); $request->session()->regenerateToken();
if ($room !== null && is_numeric($playerId)) {
$request->session()->put("room_players.{$room->id}", (int) $playerId);
}
return to_route('embedded.room', [ return to_route('embedded.room', [
'join_code' => $joinCode, 'join_code' => $joinCode,
'player_name' => $playerName, 'player_name' => $playerName,
@@ -6,9 +6,9 @@ use App\Http\Requests\ShowEmbeddedRoomRequest;
use App\Http\Resources\CurrentPlayerResource; use App\Http\Resources\CurrentPlayerResource;
use App\Http\Resources\RoomResource; use App\Http\Resources\RoomResource;
use App\Models\Room; use App\Models\Room;
use App\Models\User;
use App\Services\Rooms\RoomPageService; use App\Services\Rooms\RoomPageService;
use App\Services\Rooms\RoomParticipationService; use App\Services\Rooms\RoomParticipationService;
use App\Services\Rooms\RoomPlayerInstanceService;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -17,6 +17,7 @@ class EmbeddedRoomController extends Controller
public function __construct( public function __construct(
private readonly RoomParticipationService $roomParticipation, private readonly RoomParticipationService $roomParticipation,
private readonly RoomPageService $roomPage, private readonly RoomPageService $roomPage,
private readonly RoomPlayerInstanceService $playerInstances,
) {} ) {}
public function __invoke(ShowEmbeddedRoomRequest $request): Response public function __invoke(ShowEmbeddedRoomRequest $request): Response
@@ -25,8 +26,6 @@ class EmbeddedRoomController extends Controller
$validated = $request->validated(); $validated = $request->validated();
$user = $request->user(); $user = $request->user();
abort_unless($user instanceof User, 403);
$room = Room::query() $room = Room::query()
->where('join_code', $validated['join_code']) ->where('join_code', $validated['join_code'])
->firstOrFail(); ->firstOrFail();
@@ -34,10 +33,15 @@ class EmbeddedRoomController extends Controller
$room, $room,
$user, $user,
$validated['player_name'], $validated['player_name'],
$request->session()->get("room_players.{$room->id}"),
);
$player = $this->roomParticipation->activateInstance(
$room,
$player,
$this->playerInstances->for($request),
); );
$request->session()->put("room_players.{$room->id}", $player->id); $request->session()->put("room_players.{$room->id}", $player->id);
$request->session()->forget('embedded_login');
$pageData = $this->roomPage->prepare($room, $player, $user); $pageData = $this->roomPage->prepare($room, $player, $user);
+5
View File
@@ -10,6 +10,7 @@ use App\Models\Player;
use App\Models\Room; use App\Models\Room;
use App\Services\Rooms\RoomCreationService; use App\Services\Rooms\RoomCreationService;
use App\Services\Rooms\RoomPageService; use App\Services\Rooms\RoomPageService;
use App\Services\Rooms\RoomPlayerInstanceService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@@ -20,6 +21,7 @@ class RoomController extends Controller
public function __construct( public function __construct(
private readonly RoomCreationService $roomCreation, private readonly RoomCreationService $roomCreation,
private readonly RoomPageService $roomPage, private readonly RoomPageService $roomPage,
private readonly RoomPlayerInstanceService $playerInstances,
) {} ) {}
public function create(Request $request): Response public function create(Request $request): Response
@@ -40,6 +42,9 @@ class RoomController extends Controller
$validated, $validated,
$request->user(), $request->user(),
); );
$player->update([
'active_instance_id' => $this->playerInstances->for($request),
]);
$request->session()->put( $request->session()->put(
"room_players.{$room->id}", "room_players.{$room->id}",
@@ -4,12 +4,14 @@ namespace App\Http\Controllers;
use App\Http\Requests\JoinRoomRequest; use App\Http\Requests\JoinRoomRequest;
use App\Services\Rooms\RoomParticipationService; use App\Services\Rooms\RoomParticipationService;
use App\Services\Rooms\RoomPlayerInstanceService;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
class RoomPlayerController extends Controller class RoomPlayerController extends Controller
{ {
public function __construct( public function __construct(
private readonly RoomParticipationService $roomParticipation, private readonly RoomParticipationService $roomParticipation,
private readonly RoomPlayerInstanceService $playerInstances,
) {} ) {}
public function join(JoinRoomRequest $request): RedirectResponse public function join(JoinRoomRequest $request): RedirectResponse
@@ -23,6 +25,11 @@ class RoomPlayerController extends Controller
$request->user(), $request->user(),
is_array($sessionPlayerIds) ? $sessionPlayerIds : [], is_array($sessionPlayerIds) ? $sessionPlayerIds : [],
); );
$player = $this->roomParticipation->activateInstance(
$room,
$player,
$this->playerInstances->for($request),
);
$request->session()->put( $request->session()->put(
"room_players.{$room->id}", "room_players.{$room->id}",
@@ -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',
+44 -2
View File
@@ -3,6 +3,8 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\Room; use App\Models\Room;
use App\Services\Rooms\RoomParticipationService;
use App\Services\Rooms\RoomPlayerInstanceService;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@@ -10,6 +12,11 @@ use Symfony\Component\HttpFoundation\Response;
class EnsureRoomParticipant class EnsureRoomParticipant
{ {
public function __construct(
private readonly RoomParticipationService $roomParticipation,
private readonly RoomPlayerInstanceService $playerInstances,
) {}
/** /**
* Handle an incoming request. * Handle an incoming request.
* *
@@ -44,13 +51,43 @@ class EnsureRoomParticipant
} }
$playerId = $request->session()->get($sessionKey); $playerId = $request->session()->get($sessionKey);
$hasSessionPlayer = is_numeric($playerId);
$instanceId = $this->playerInstances->for($request);
$player = is_numeric($playerId) $player = $hasSessionPlayer
? $room->players()->find((int) $playerId) ? $room->players()->find((int) $playerId)
: null; : null;
if ($player === null && $playerId) { if ($player === null && $hasSessionPlayer) {
$request->session()->forget($sessionKey); $request->session()->forget($sessionKey);
return to_route('room.join', [
'join_code' => $room->join_code,
])->withErrors([
'join_code' => 'Diese Spieler-Instanz ist nicht mehr aktiv.',
]);
}
if (
$player !== null
&& $player->active_instance_id !== null
&& $player->active_instance_id !== $instanceId
) {
$request->session()->forget($sessionKey);
return to_route('room.join', [
'join_code' => $room->join_code,
])->withErrors([
'join_code' => 'Dein Account ist diesem Raum in einer anderen Instanz beigetreten.',
]);
}
if ($player !== null) {
$player = $this->roomParticipation->activateInstance(
$room,
$player,
$instanceId,
);
} }
if ($player === null && $request->user() !== null) { if ($player === null && $request->user() !== null) {
@@ -59,6 +96,11 @@ class EnsureRoomParticipant
->first(); ->first();
if ($player !== null) { if ($player !== null) {
$player = $this->roomParticipation->activateInstance(
$room,
$player,
$instanceId,
);
$request->session()->put($sessionKey, $player->id); $request->session()->put($sessionKey, $player->id);
} }
} }
+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);
} }
/** /**
+32 -8
View File
@@ -13,12 +13,14 @@ use Inertia\Inertia;
class ShowEmbeddedRoomRequest extends FormRequest class ShowEmbeddedRoomRequest extends FormRequest
{ {
private const string PLAYER_NAME_TAKEN_MESSAGE = 'Dieser Anzeigename ist in dem Raum bereits vorhanden.';
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.
*/ */
public function authorize(): bool public function authorize(): bool
{ {
return $this->user() !== null; return true;
} }
protected function prepareForValidation(): void protected function prepareForValidation(): void
@@ -67,23 +69,36 @@ class ShowEmbeddedRoomRequest extends FormRequest
return; return;
} }
if ($room === null || $this->user() === null) { if ($room === null) {
return; return;
} }
$sessionPlayerId = $this->session()->get("room_players.{$room->id}");
$existingPlayer = null;
if ($this->user() !== null) {
$existingPlayer = $room->players()
->where('user_id', $this->user()->id)
->first();
}
if ($existingPlayer === null && is_numeric($sessionPlayerId)) {
$existingPlayer = $room->players()->find((int) $sessionPlayerId);
}
$nameIsTaken = Player::query() $nameIsTaken = Player::query()
->where('room_id', $room->id) ->where('room_id', $room->id)
->where('name', $this->input('player_name')) ->where('name', $this->input('player_name'))
->where(function ($query): void { ->when(
$query->whereNull('user_id') $existingPlayer !== null,
->orWhere('user_id', '!=', $this->user()->id); fn ($query) => $query->whereKeyNot($existingPlayer->id),
}) )
->exists(); ->exists();
if ($nameIsTaken) { if ($nameIsTaken) {
$validator->errors()->add( $validator->errors()->add(
'player_name', 'player_name',
'Dieser Anzeigename ist in dem Raum bereits vorhanden.', self::PLAYER_NAME_TAKEN_MESSAGE,
); );
} }
}, },
@@ -101,9 +116,18 @@ class ShowEmbeddedRoomRequest extends FormRequest
protected function failedValidation(Validator $validator): never protected function failedValidation(Validator $validator): never
{ {
$message = $validator->errors()->first();
$showLoginAction = $this->user() === null
&& $message === self::PLAYER_NAME_TAKEN_MESSAGE;
if ($showLoginAction) {
$this->session()->put('url.intended', $this->fullUrl());
}
throw new HttpResponseException( throw new HttpResponseException(
Inertia::render('embedded/Error', [ Inertia::render('embedded/Error', [
'message' => $validator->errors()->first(), 'message' => $message,
'showLoginAction' => $showLoginAction,
])->toResponse($this)->setStatusCode(422), ])->toResponse($this)->setStatusCode(422),
); );
} }
@@ -19,6 +19,7 @@ class CurrentPlayerResource extends JsonResource
return [ return [
'id' => $player->id, 'id' => $player->id,
'instance_id' => $player->active_instance_id,
'name' => $player->name, 'name' => $player->name,
'is_moderator' => $player->is_moderator, 'is_moderator' => $player->is_moderator,
'current_vote' => $pageData->currentVote, 'current_vote' => $pageData->currentVote,
+2
View File
@@ -12,6 +12,7 @@ use Illuminate\Support\Carbon;
* @property int $id * @property int $id
* @property string $room_id * @property string $room_id
* @property int|null $user_id * @property int|null $user_id
* @property string|null $active_instance_id
* @property string $name * @property string $name
* @property bool $is_moderator * @property bool $is_moderator
* @property Carbon|null $last_seen_at * @property Carbon|null $last_seen_at
@@ -25,6 +26,7 @@ class Player extends Model
protected $fillable = [ protected $fillable = [
'room_id', 'room_id',
'user_id', 'user_id',
'active_instance_id',
'name', 'name',
'is_moderator', 'is_moderator',
'last_seen_at', 'last_seen_at',
-1
View File
@@ -51,7 +51,6 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::loginView(fn (Request $request) => Inertia::render('auth/Login', [ Fortify::loginView(fn (Request $request) => Inertia::render('auth/Login', [
'canResetPassword' => Features::enabled(Features::resetPasswords()), 'canResetPassword' => Features::enabled(Features::resetPasswords()),
'status' => $request->session()->get('status'), 'status' => $request->session()->get('status'),
'rememberByDefault' => $request->session()->get('embedded_login', false),
])); ]));
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/ResetPassword', [ Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/ResetPassword', [
+134 -17
View File
@@ -102,9 +102,13 @@ class RoomParticipationService
return [$room, $player]; return [$room, $player];
} }
public function joinEmbedded(Room $room, User $user, string $playerName): Player public function joinEmbedded(
{ Room $room,
[$player, $change, $stateVersion] = DB::transaction(function () use ($room, $user, $playerName): array { ?User $user,
string $playerName,
mixed $sessionPlayerId,
): Player {
[$player, $change, $stateVersion] = DB::transaction(function () use ($room, $user, $playerName, $sessionPlayerId): array {
$lockedRoom = Room::query() $lockedRoom = Room::query()
->lockForUpdate() ->lockForUpdate()
->findOrFail($room->id); ->findOrFail($room->id);
@@ -115,12 +119,18 @@ class RoomParticipationService
]); ]);
} }
$player = $this->findExistingEmbeddedPlayer(
$lockedRoom,
$user,
$sessionPlayerId,
);
$nameIsTaken = $lockedRoom->players() $nameIsTaken = $lockedRoom->players()
->where('name', $playerName) ->where('name', $playerName)
->where(function ($query) use ($user): void { ->when(
$query->whereNull('user_id') $player !== null,
->orWhere('user_id', '!=', $user->id); fn ($query) => $query->whereKeyNot($player->id),
}) )
->exists(); ->exists();
if ($nameIsTaken) { if ($nameIsTaken) {
@@ -129,14 +139,11 @@ class RoomParticipationService
]); ]);
} }
$player = $lockedRoom->players()
->where('user_id', $user->id)
->first();
$change = null; $change = null;
if ($player === null) { if ($player === null) {
$player = $lockedRoom->players()->create([ $player = $lockedRoom->players()->create([
'user_id' => $user->id, 'user_id' => $user?->id,
'name' => $playerName, 'name' => $playerName,
'is_moderator' => false, 'is_moderator' => false,
'last_seen_at' => now(), 'last_seen_at' => now(),
@@ -149,11 +156,24 @@ class RoomParticipationService
$change = RoomStateChange::PlayerUpdated; $change = RoomStateChange::PlayerUpdated;
} }
$player->update([ $updates = [
'name' => $playerName, 'name' => $playerName,
'last_seen_at' => now(), 'last_seen_at' => now(),
'inactive_at' => null, 'inactive_at' => null,
]); ];
if (
$user !== null
&& $player->user_id === null
&& ! $lockedRoom->players()
->where('user_id', $user->id)
->whereKeyNot($player->id)
->exists()
) {
$updates['user_id'] = $user->id;
}
$player->update($updates);
} }
if ($change === null) { if ($change === null) {
@@ -180,20 +200,117 @@ class RoomParticipationService
return $player; return $player;
} }
public function kick(Room $room, Player $player, User $user): void private function findExistingEmbeddedPlayer(
Room $room,
?User $user,
mixed $sessionPlayerId,
): ?Player {
if ($user !== null) {
$userPlayer = $room->players()
->where('user_id', $user->id)
->lockForUpdate()
->first();
if ($userPlayer !== null) {
return $userPlayer;
}
}
if (is_numeric($sessionPlayerId)) {
$sessionPlayer = $room->players()
->whereKey((int) $sessionPlayerId)
->lockForUpdate()
->first();
if ($sessionPlayer !== null) {
return $sessionPlayer;
}
}
return null;
}
public function activateInstance(Room $room, Player $player, string $instanceId): Player
{ {
$stateVersion = DB::transaction(function () use ($room, $player, $user): ?int { [$activePlayer, $replacedInstanceId, $stateVersion] = DB::transaction(function () use ($room, $player, $instanceId): array {
$lockedRoom = Room::query()
->lockForUpdate()
->findOrFail($room->id);
$lockedPlayer = $lockedRoom->players()
->lockForUpdate()
->findOrFail($player->id);
if ($lockedPlayer->active_instance_id === $instanceId) {
return [$lockedPlayer, null, null];
}
$replacedInstanceId = $lockedPlayer->active_instance_id;
$lockedPlayer->update([
'active_instance_id' => $instanceId,
'last_seen_at' => now(),
]);
if ($replacedInstanceId === null) {
return [$lockedPlayer, null, null];
}
$lockedRoom->update([
'last_activity_at' => now(),
'state_version' => $lockedRoom->state_version + 1,
]);
return [$lockedPlayer, $replacedInstanceId, $lockedRoom->state_version];
});
if (is_string($replacedInstanceId) && is_int($stateVersion)) {
RoomStateChanged::dispatch(
$room->id,
$stateVersion,
RoomStateChange::PlayerInstanceReplaced,
$this->realtimeState->for($room),
$activePlayer->id,
$replacedInstanceId,
);
}
return $activePlayer;
}
public function kick(
Room $room,
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;
@@ -0,0 +1,25 @@
<?php
namespace App\Services\Rooms;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class RoomPlayerInstanceService
{
private const string SESSION_KEY = 'room_player_instance_id';
public function for(Request $request): string
{
$instanceId = $request->session()->get(self::SESSION_KEY);
if (is_string($instanceId) && Str::isUuid($instanceId)) {
return $instanceId;
}
$instanceId = (string) Str::uuid();
$request->session()->put(self::SESSION_KEY, $instanceId);
return $instanceId;
}
}
-7
View File
@@ -18,13 +18,6 @@ return Application::configure(basePath: dirname(__DIR__))
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*'); $middleware->trustProxies(at: '*');
$middleware->redirectGuestsTo(function (Request $request): string {
if ($request->isMethod('GET') && $request->is('embedded/scrum-poker')) {
$request->session()->put('embedded_login', true);
}
return route('login');
});
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']); $middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->alias([ $middleware->alias([
'room.player' => EnsureRoomParticipant::class, 'room.player' => EnsureRoomParticipant::class,
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('players', function (Blueprint $table) {
$table->uuid('active_instance_id')->nullable()->after('user_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('players', function (Blueprint $table) {
$table->dropColumn('active_instance_id');
});
}
};
+2
View File
@@ -38,6 +38,8 @@ http {
location = /index.php { location = /index.php {
include /etc/nginx/fastcgi_params; include /etc/nginx/fastcgi_params;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 32k;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_PROXY ''; fastcgi_param HTTP_PROXY '';
fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for; fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for;
+4 -3
View File
@@ -4,6 +4,7 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "scrum-poker-internal",
"dependencies": { "dependencies": {
"@inertiajs/vite": "^3.0.0", "@inertiajs/vite": "^3.0.0",
"@inertiajs/vue3": "^3.0.0", "@inertiajs/vue3": "^3.0.0",
@@ -4959,9 +4960,9 @@
} }
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.3.1", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -16,6 +16,7 @@ const props = withDefaults(defineProps<RoomExperienceProps>(), {
const page = usePage(); const page = usePage();
const presenterMode = ref(false); const presenterMode = ref(false);
const roomNotice = computed(() => page.props.roomNotice); const roomNotice = computed(() => page.props.roomNotice);
const isAuthenticated = computed(() => page.props.auth.user !== null);
const embeddedBackgroundColor = computed(() => { const embeddedBackgroundColor = computed(() => {
if (!props.embedded) { if (!props.embedded) {
return undefined; return undefined;
@@ -67,8 +68,14 @@ 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"
:embedded="embedded" :can-manage-participants="
realtimeCurrentPlayer.is_moderator ||
realtimeRoom.allow_participants_to_manage_rounds
"
:current-player-id="realtimeCurrentPlayer.id"
:can-logout="embedded && isAuthenticated"
:player-name="realtimeCurrentPlayer.name" :player-name="realtimeCurrentPlayer.name"
v-model:presenter-mode="presenterMode" v-model:presenter-mode="presenterMode"
/> />
+198 -4
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"
> >
@@ -50,7 +99,7 @@ async function copyInviteLink(): Promise<void> {
<div class="flex flex-col items-end gap-2"> <div class="flex flex-col items-end gap-2">
<Link <Link
v-if="embedded" v-if="canLogout"
:href=" :href="
embeddedLogout({ embeddedLogout({
query: { query: {
@@ -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 }"
@@ -42,6 +42,16 @@ export function useRoomRealtime(
return; return;
} }
if (
payload.change === 'player_instance_replaced' &&
payload.affected_player_id === currentPlayer.value.id &&
payload.affected_instance_id === currentPlayer.value.instance_id
) {
router.visit(home().url, { replace: true });
return;
}
if (payload.change === 'room_closed') { if (payload.change === 'room_closed') {
router.visit( router.visit(
join({ query: { join_code: room.value.join_code } }).url, join({ query: { join_code: room.value.join_code } }).url,
-2
View File
@@ -23,7 +23,6 @@ defineOptions({
defineProps<{ defineProps<{
status?: string; status?: string;
canResetPassword: boolean; canResetPassword: boolean;
rememberByDefault: boolean;
}>(); }>();
</script> </script>
@@ -89,7 +88,6 @@ defineProps<{
<Checkbox <Checkbox
id="remember" id="remember"
name="remember" name="remember"
:default-value="rememberByDefault"
:tabindex="3" :tabindex="3"
/> />
<span>Remember me</span> <span>Remember me</span>
+11 -1
View File
@@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import { CircleAlert } from '@lucide/vue'; import { CircleAlert } from '@lucide/vue';
import { login } from '@/routes';
defineProps<{ defineProps<{
message: string; message: string;
showLoginAction: boolean;
}>(); }>();
</script> </script>
@@ -19,6 +22,13 @@ defineProps<{
<CircleAlert class="size-10 text-amber-300" /> <CircleAlert class="size-10 text-amber-300" />
<h1 class="text-2xl font-bold">Raum kann nicht geöffnet werden</h1> <h1 class="text-2xl font-bold">Raum kann nicht geöffnet werden</h1>
<p class="text-slate-300">{{ message }}</p> <p class="text-slate-300">{{ message }}</p>
<Link
v-if="showLoginAction"
:href="login()"
class="inline-flex rounded-lg bg-white px-4 py-2 font-semibold text-slate-900 transition hover:bg-slate-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
>
Anmelden
</Link>
<p class="text-sm text-slate-400"> <p class="text-sm text-slate-400">
Bitte ändere Raumcode oder Anzeigename auf der Home-Seite der Bitte ändere Raumcode oder Anzeigename auf der Home-Seite der
Fillibri Hub App und öffne Scrum Poker erneut. Fillibri Hub App und öffne Scrum Poker erneut.
+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
" "
+7 -2
View File
@@ -30,6 +30,7 @@ export type RoomPlayer = {
export type CurrentRoomPlayer = { export type CurrentRoomPlayer = {
id: number; id: number;
instance_id: string;
name: string; name: string;
is_moderator: boolean; is_moderator: boolean;
current_vote: string | null; current_vote: string | null;
@@ -110,11 +111,13 @@ 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; canLogout: boolean;
playerName: string; playerName: string;
}; };
@@ -161,6 +164,7 @@ export type RoomStateChange =
| 'player_joined' | 'player_joined'
| 'player_updated' | 'player_updated'
| 'player_kicked' | 'player_kicked'
| 'player_instance_replaced'
| 'vote_changed' | 'vote_changed'
| 'cards_revealed' | 'cards_revealed'
| 'votes_reset' | 'votes_reset'
@@ -175,6 +179,7 @@ export type RoomStateChangedPayload = {
change: RoomStateChange; change: RoomStateChange;
state: RoomRealtimeState | null; state: RoomRealtimeState | null;
affected_player_id?: number; affected_player_id?: number;
affected_instance_id?: string;
}; };
export type RoomRealtimeState = Pick< export type RoomRealtimeState = Pick<
+4 -5
View File
@@ -25,10 +25,8 @@ Route::get('/room/join', RoomJoinController::class)->name('room.join');
Route::post('room/join', [RoomPlayerController::class, 'join'])->name('room.code'); Route::post('room/join', [RoomPlayerController::class, 'join'])->name('room.code');
Route::post('/room', [RoomController::class, 'store'])->name('room.store'); Route::post('/room', [RoomController::class, 'store'])->name('room.store');
Route::middleware(['auth', 'verified'])->group(function () { Route::get('/embedded/scrum-poker', EmbeddedRoomController::class)
Route::get('/embedded/scrum-poker', EmbeddedRoomController::class)
->name('embedded.room'); ->name('embedded.room');
});
Route::post('/embedded/scrum-poker/logout', EmbeddedLogoutController::class) Route::post('/embedded/scrum-poker/logout', EmbeddedLogoutController::class)
->middleware('auth') ->middleware('auth')
@@ -45,10 +43,11 @@ 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)
->name('room.player.destroy');
}); });
Route::delete('/rooms/{room}/players/{player}', RoomPlayerKickController::class)
->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');
Route::post('/rooms/{room}/votes', [VoteController::class, 'store']) Route::post('/rooms/{room}/votes', [VoteController::class, 'store'])
+151 -30
View File
@@ -1,7 +1,10 @@
<?php <?php
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Models\Room; use App\Models\Room;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Event;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
function createEmbeddedRoom(array $attributes = []): Room function createEmbeddedRoom(array $attributes = []): Room
@@ -19,22 +22,43 @@ function createEmbeddedRoom(array $attributes = []): Room
]); ]);
} }
test('embedded room access sends guests through login and remembers the intended room', function () { test('a guest automatically joins the embedded room', function () {
$room = createEmbeddedRoom(); $room = createEmbeddedRoom();
$url = route('embedded.room', [ $response = $this->get(route('embedded.room', [
'join_code' => $room->join_code, 'join_code' => strtolower($room->join_code),
'player_name' => 'Nico', 'player_name' => ' Nico ',
]))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('embedded/Room')
->where('room.id', $room->id)
->where('currentPlayer.name', 'Nico'));
$player = $room->players()->sole();
expect($player->user_id)->toBeNull()
->and($player->is_moderator)->toBeFalse();
$response->assertSessionHas("room_players.{$room->id}", $player->id);
});
test('embedded guest access reuses the session player and applies a changed display name', function () {
$room = createEmbeddedRoom();
$player = $room->players()->create([
'name' => 'Old name',
'inactive_at' => now(),
]); ]);
$this->get($url) $this->withSession(["room_players.{$room->id}" => $player->id])
->assertRedirect(route('login')) ->get(route('embedded.room', [
->assertSessionHas('embedded_login', true) 'join_code' => $room->join_code,
->assertSessionHas('url.intended', $url); 'player_name' => 'New name',
]))
->assertOk();
$this->get(route('login')) expect($room->players()->count())->toBe(1)
->assertInertia(fn (Assert $page) => $page ->and($player->fresh()->name)->toBe('New name')
->component('auth/Login') ->and($player->fresh()->inactive_at)->toBeNull();
->where('rememberByDefault', true));
}); });
test('an authenticated user automatically joins the embedded room', function () { test('an authenticated user automatically joins the embedded room', function () {
@@ -83,47 +107,144 @@ test('embedded access reuses the user player and applies a changed display name'
->and($player->fresh()->inactive_at)->toBeNull(); ->and($player->fresh()->inactive_at)->toBeNull();
}); });
test('embedded access shows a dedicated error when the display name is taken', function () { test('an embedded guest keeps the same player after logging in separately', function () {
$user = User::factory()->create(); $user = User::factory()->create([
'email' => 'nico@example.com',
]);
$room = createEmbeddedRoom(); $room = createEmbeddedRoom();
$room->players()->create(['name' => 'Nico']); $url = route('embedded.room', [
$this->actingAs($user)
->get(route('embedded.room', [
'join_code' => $room->join_code, 'join_code' => $room->join_code,
'player_name' => 'Nico', 'player_name' => 'Nico',
])) ]);
$this->get($url)->assertOk();
$player = $room->players()->sole();
$this->post(route('login.store'), [
'email' => 'nico@example.com',
'password' => 'password',
])->assertRedirect();
$this->get($url)->assertOk();
expect($room->players()->count())->toBe(1)
->and($player->fresh()->user_id)->toBe($user->id);
});
test('embedded guest access shows a dedicated error when the display name is taken', function () {
$user = User::factory()->create([
'email' => 'nico@example.com',
]);
$room = createEmbeddedRoom();
$room->players()->create([
'user_id' => $user->id,
'name' => 'Nico',
]);
$url = route('embedded.room', [
'join_code' => $room->join_code,
'player_name' => 'Nico',
]);
$this->get($url)
->assertUnprocessable() ->assertUnprocessable()
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('embedded/Error') ->component('embedded/Error')
->where('message', 'Dieser Anzeigename ist in dem Raum bereits vorhanden.')); ->where('message', 'Dieser Anzeigename ist in dem Raum bereits vorhanden.')
->where('showLoginAction', true))
->assertSessionHas('url.intended', $url);
$this->post(route('login.store'), [
'email' => 'nico@example.com',
'password' => 'password',
])->assertRedirect($url);
$this->get($url)
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('embedded/Room')
->where('currentPlayer.name', 'Nico'));
});
test('a new account instance takes over the embedded player and kicks the previous instance', function () {
Event::fake([RoomStateChanged::class]);
$user = User::factory()->create();
$room = createEmbeddedRoom();
$player = $room->players()->create([
'user_id' => $user->id,
'name' => 'Nico',
'active_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c16',
]);
$url = route('embedded.room', [
'join_code' => $room->join_code,
'player_name' => 'Nico',
]);
$this->actingAs($user)
->withSession([
'room_player_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c17',
])
->get($url)
->assertOk()
->assertInertia(fn (Assert $page) => $page
->where('currentPlayer.id', $player->id)
->where('currentPlayer.instance_id', '019b8f80-37c4-71a3-b415-5f2697332c17'));
expect($player->fresh()->active_instance_id)
->toBe('019b8f80-37c4-71a3-b415-5f2697332c17');
Event::assertDispatched(
RoomStateChanged::class,
fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::PlayerInstanceReplaced
&& $event->affectedPlayerId === $player->id
&& $event->affectedInstanceId === '019b8f80-37c4-71a3-b415-5f2697332c16',
);
$this->withSession([
"room_players.{$room->id}" => $player->id,
'room_player_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c16',
])
->post(route('room.vote.store', $room), ['value' => '5'])
->assertRedirect(route('room.join', ['join_code' => $room->join_code]));
$this->assertDatabaseCount('votes', 0);
}); });
test('embedded access shows a dedicated error for an unknown room code', function () { test('embedded access shows a dedicated error for an unknown room code', function () {
$this->actingAs(User::factory()->create()) $this->get(route('embedded.room', [
->get(route('embedded.room', [
'join_code' => 'ABC234', 'join_code' => 'ABC234',
'player_name' => 'Nico', 'player_name' => 'Nico',
])) ]))
->assertUnprocessable() ->assertUnprocessable()
->assertInertia(fn (Assert $page) => $page ->assertInertia(fn (Assert $page) => $page
->component('embedded/Error') ->component('embedded/Error')
->where('message', 'Dieser Raumcode existiert nicht.')); ->where('message', 'Dieser Raumcode existiert nicht.')
->where('showLoginAction', false));
}); });
test('embedded logout returns to the same configured room and signs the user out', function () { test('embedded logout returns to the same room as a guest without duplicating the player', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$room = createEmbeddedRoom(); $room = createEmbeddedRoom();
$url = route('embedded.room', [
'join_code' => $room->join_code,
'player_name' => 'Nico',
]);
$this->actingAs($user) $this->actingAs($user)->get($url)->assertOk();
$player = $room->players()->sole();
$this
->followingRedirects()
->post(route('embedded.logout', [ ->post(route('embedded.logout', [
'join_code' => $room->join_code, 'join_code' => $room->join_code,
'player_name' => 'Nico', 'player_name' => 'Nico',
])) ]))
->assertRedirect(route('embedded.room', [ ->assertOk()
'join_code' => $room->join_code, ->assertInertia(fn (Assert $page) => $page
'player_name' => 'Nico', ->component('embedded/Room')
])); ->where('currentPlayer.id', $player->id));
$this->assertGuest(); $this->assertGuest()
->assertDatabaseCount('players', 1);
}); });
+19
View File
@@ -45,6 +45,25 @@ test('room state broadcasts synchronously expose sanitized state', function () {
]); ]);
}); });
test('player instance replacement broadcasts the displaced instance', function () {
$event = new RoomStateChanged(
'019b8f80-37c4-71a3-b415-5f2697332c16',
8,
RoomStateChange::PlayerInstanceReplaced,
null,
42,
'019b8f80-37c4-71a3-b415-5f2697332c17',
);
expect($event->broadcastWith())->toBe([
'version' => 8,
'change' => 'player_instance_replaced',
'state' => null,
'affected_player_id' => 42,
'affected_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c17',
]);
});
test('joining a room broadcasts the new room version', function () { test('joining a room broadcasts the new room version', function () {
Event::fake([RoomStateChanged::class]); Event::fake([RoomStateChanged::class]);
$room = createBroadcastRoom(); $room = createBroadcastRoom();
+33
View File
@@ -1,11 +1,14 @@
<?php <?php
use App\Enums\RoomStateChange;
use App\Events\RoomStateChanged;
use App\Jobs\CloseInactiveRooms; use App\Jobs\CloseInactiveRooms;
use App\Models\Deck; use App\Models\Deck;
use App\Models\Room; use App\Models\Room;
use App\Models\User; use App\Models\User;
use App\Models\Vote; use App\Models\Vote;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
function createLifecycleRoom(array $attributes = []): Room function createLifecycleRoom(array $attributes = []): Room
@@ -156,6 +159,36 @@ test('an authenticated player rejoins with the existing room identity', function
$response->assertSessionHas("room_players.{$room->id}", $player->id); $response->assertSessionHas("room_players.{$room->id}", $player->id);
}); });
test('a new account instance takes over the player during a regular room join', function () {
$user = User::factory()->create();
$room = createLifecycleRoom();
$player = $room->players()->create([
'user_id' => $user->id,
'name' => 'Room nickname',
'active_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c16',
]);
Event::fake([RoomStateChanged::class]);
$this->actingAs($user)
->withSession([
'room_player_instance_id' => '019b8f80-37c4-71a3-b415-5f2697332c17',
])
->post(route('room.code'), [
'join_code' => $room->join_code,
'player_name' => 'Room nickname',
])
->assertRedirect(route('room.show', $room));
expect($player->fresh()->active_instance_id)
->toBe('019b8f80-37c4-71a3-b415-5f2697332c17');
Event::assertDispatched(
RoomStateChanged::class,
fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::PlayerInstanceReplaced
&& $event->affectedPlayerId === $player->id,
);
});
test('an authenticated player can restore room access without the previous session', function () { test('an authenticated player can restore room access without the previous session', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$room = createLifecycleRoom(['owner_id' => $user->id]); $room = createLifecycleRoom(['owner_id' => $user->id]);
+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);