mirror of
https://github.com/ericasch6/scrum-poker-internal.git
synced 2026-10-15 11:26:08 +00:00
Handle authenticated room instance takeover
This commit is contained in:
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Http\Resources\RoomResource;
|
|||||||
use App\Models\Room;
|
use App\Models\Room;
|
||||||
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;
|
||||||
|
|
||||||
@@ -16,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
|
||||||
@@ -33,6 +35,11 @@ class EmbeddedRoomController extends Controller
|
|||||||
$validated['player_name'],
|
$validated['player_name'],
|
||||||
$request->session()->get("room_players.{$room->id}"),
|
$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);
|
||||||
|
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ 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.
|
||||||
*/
|
*/
|
||||||
@@ -72,16 +74,18 @@ class ShowEmbeddedRoomRequest extends FormRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
$sessionPlayerId = $this->session()->get("room_players.{$room->id}");
|
$sessionPlayerId = $this->session()->get("room_players.{$room->id}");
|
||||||
$existingPlayer = is_numeric($sessionPlayerId)
|
$existingPlayer = null;
|
||||||
? $room->players()->find((int) $sessionPlayerId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if ($existingPlayer === null && $this->user() !== null) {
|
if ($this->user() !== null) {
|
||||||
$existingPlayer = $room->players()
|
$existingPlayer = $room->players()
|
||||||
->where('user_id', $this->user()->id)
|
->where('user_id', $this->user()->id)
|
||||||
->first();
|
->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'))
|
||||||
@@ -94,7 +98,7 @@ class ShowEmbeddedRoomRequest extends FormRequest
|
|||||||
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -112,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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -205,6 +205,17 @@ class RoomParticipationService
|
|||||||
?User $user,
|
?User $user,
|
||||||
mixed $sessionPlayerId,
|
mixed $sessionPlayerId,
|
||||||
): ?Player {
|
): ?Player {
|
||||||
|
if ($user !== null) {
|
||||||
|
$userPlayer = $room->players()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($userPlayer !== null) {
|
||||||
|
return $userPlayer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (is_numeric($sessionPlayerId)) {
|
if (is_numeric($sessionPlayerId)) {
|
||||||
$sessionPlayer = $room->players()
|
$sessionPlayer = $room->players()
|
||||||
->whereKey((int) $sessionPlayerId)
|
->whereKey((int) $sessionPlayerId)
|
||||||
@@ -216,14 +227,53 @@ class RoomParticipationService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user === null) {
|
return null;
|
||||||
return null;
|
}
|
||||||
|
|
||||||
|
public function activateInstance(Room $room, Player $player, string $instanceId): Player
|
||||||
|
{
|
||||||
|
[$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 $room->players()
|
return $activePlayer;
|
||||||
->where('user_id', $user->id)
|
|
||||||
->lockForUpdate()
|
|
||||||
->first();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function kick(
|
public function kick(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -163,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'
|
||||||
@@ -177,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<
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -130,17 +133,81 @@ test('an embedded guest keeps the same player after logging in separately', func
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('embedded guest access shows a dedicated error when the display name is taken', function () {
|
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 = createEmbeddedRoom();
|
||||||
$room->players()->create(['name' => 'Nico']);
|
$room->players()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
$this->get(route('embedded.room', [
|
'name' => 'Nico',
|
||||||
|
]);
|
||||||
|
$url = route('embedded.room', [
|
||||||
'join_code' => $room->join_code,
|
'join_code' => $room->join_code,
|
||||||
'player_name' => 'Nico',
|
'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 () {
|
||||||
@@ -151,7 +218,8 @@ test('embedded access shows a dedicated error for an unknown room code', functio
|
|||||||
->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 room as a guest without duplicating the player', function () {
|
test('embedded logout returns to the same room as a guest without duplicating the player', function () {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
Reference in New Issue
Block a user