From e58a398c07e355afadaf9e883b226c6e1a991a3b Mon Sep 17 00:00:00 2001 From: Twyco Date: Wed, 2 Sep 2026 09:37:11 +0200 Subject: [PATCH 1/2] Added inern app integration --- app/Enums/RoomStateChange.php | 2 + .../Controllers/EmbeddedLogoutController.php | 28 ++++ .../Controllers/EmbeddedRoomController.php | 49 ++++++ .../Controllers/RoomSettingsController.php | 52 ++++++ app/Http/Controllers/RoundController.php | 10 +- app/Http/Requests/ShowEmbeddedRoomRequest.php | 110 ++++++++++++ .../Requests/UpdateRoomSettingsRequest.php | 33 ++++ app/Http/Resources/RoomResource.php | 2 + app/Jobs/CloseInactiveRooms.php | 8 +- app/Models/Room.php | 11 ++ app/Providers/FortifyServiceProvider.php | 1 + .../Rooms/RoomParticipationService.php | 78 +++++++++ .../Rooms/RoomRealtimeStateService.php | 2 + app/Services/Rooms/RoomSettingsService.php | 50 ++++++ bootstrap/app.php | 7 + ...5_add_embedded_settings_to_rooms_table.php | 32 ++++ resources/js/app.ts | 4 +- resources/js/components/MyRooms.vue | 11 +- .../js/components/rooms/RoomExperience.vue | 72 ++++++++ resources/js/components/rooms/RoomHeader.vue | 21 ++- resources/js/components/rooms/RoomTable.vue | 5 +- resources/js/pages/auth/Login.vue | 8 +- resources/js/pages/embedded/Error.vue | 28 ++++ resources/js/pages/embedded/Room.vue | 14 ++ resources/js/pages/rooms/Settings.vue | 103 ++++++++++++ resources/js/pages/rooms/Show.vue | 57 +------ resources/js/types/room.ts | 26 ++- routes/room.php | 16 ++ tests/Feature/EmbeddedRoomTest.php | 129 ++++++++++++++ tests/Feature/RoomSettingsTest.php | 158 ++++++++++++++++++ 30 files changed, 1059 insertions(+), 68 deletions(-) create mode 100644 app/Http/Controllers/EmbeddedLogoutController.php create mode 100644 app/Http/Controllers/EmbeddedRoomController.php create mode 100644 app/Http/Controllers/RoomSettingsController.php create mode 100644 app/Http/Requests/ShowEmbeddedRoomRequest.php create mode 100644 app/Http/Requests/UpdateRoomSettingsRequest.php create mode 100644 app/Services/Rooms/RoomSettingsService.php create mode 100644 database/migrations/2026_09_01_134545_add_embedded_settings_to_rooms_table.php create mode 100644 resources/js/components/rooms/RoomExperience.vue create mode 100644 resources/js/pages/embedded/Error.vue create mode 100644 resources/js/pages/embedded/Room.vue create mode 100644 resources/js/pages/rooms/Settings.vue create mode 100644 tests/Feature/EmbeddedRoomTest.php create mode 100644 tests/Feature/RoomSettingsTest.php diff --git a/app/Enums/RoomStateChange.php b/app/Enums/RoomStateChange.php index 53de42e..78b6001 100644 --- a/app/Enums/RoomStateChange.php +++ b/app/Enums/RoomStateChange.php @@ -5,6 +5,7 @@ namespace App\Enums; enum RoomStateChange: string { case PlayerJoined = 'player_joined'; + case PlayerUpdated = 'player_updated'; case VoteChanged = 'vote_changed'; case CardsRevealed = 'cards_revealed'; case VotesReset = 'votes_reset'; @@ -12,4 +13,5 @@ enum RoomStateChange: string case RoomReopened = 'room_reopened'; case RoomClosed = 'room_closed'; case RoomDeleted = 'room_deleted'; + case RoomSettingsUpdated = 'room_settings_updated'; } diff --git a/app/Http/Controllers/EmbeddedLogoutController.php b/app/Http/Controllers/EmbeddedLogoutController.php new file mode 100644 index 0000000..f920573 --- /dev/null +++ b/app/Http/Controllers/EmbeddedLogoutController.php @@ -0,0 +1,28 @@ +string('join_code')->trim()->upper()->toString(); + $playerName = $request->string('player_name')->trim()->toString(); + + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return to_route('embedded.room', [ + 'join_code' => $joinCode, + 'player_name' => $playerName, + ]); + } +} diff --git a/app/Http/Controllers/EmbeddedRoomController.php b/app/Http/Controllers/EmbeddedRoomController.php new file mode 100644 index 0000000..0c363f6 --- /dev/null +++ b/app/Http/Controllers/EmbeddedRoomController.php @@ -0,0 +1,49 @@ +validated(); + $user = $request->user(); + + abort_unless($user instanceof User, 403); + + $room = Room::query() + ->where('join_code', $validated['join_code']) + ->firstOrFail(); + $player = $this->roomParticipation->joinEmbedded( + $room, + $user, + $validated['player_name'], + ); + + $request->session()->put("room_players.{$room->id}", $player->id); + $request->session()->forget('embedded_login'); + + $pageData = $this->roomPage->prepare($room, $player, $user); + + return Inertia::render('embedded/Room', [ + 'room' => (new RoomResource($pageData))->resolve($request), + 'currentPlayer' => (new CurrentPlayerResource($pageData))->resolve($request), + ]); + } +} diff --git a/app/Http/Controllers/RoomSettingsController.php b/app/Http/Controllers/RoomSettingsController.php new file mode 100644 index 0000000..02af84f --- /dev/null +++ b/app/Http/Controllers/RoomSettingsController.php @@ -0,0 +1,52 @@ +user()?->id === $room->owner_id, 403); + + return Inertia::render('rooms/Settings', [ + 'room' => [ + 'id' => $room->id, + 'title' => $room->title, + 'join_code' => $room->join_code, + 'allow_participants_to_manage_rounds' => $room->allow_participants_to_manage_rounds, + 'is_persistent' => $room->is_persistent, + ], + ]); + } + + public function update(UpdateRoomSettingsRequest $request, Room $room): RedirectResponse + { + $user = $request->user(); + + abort_unless($user instanceof User, 403); + + /** @var array{allow_participants_to_manage_rounds: bool, is_persistent: bool} $settings */ + $settings = $request->validated(); + $this->roomSettings->update($room, $user, $settings); + + Inertia::flash('toast', [ + 'type' => 'success', + 'message' => 'Die Raumeinstellungen wurden gespeichert.', + ]); + + return back(); + } +} diff --git a/app/Http/Controllers/RoundController.php b/app/Http/Controllers/RoundController.php index 674e080..8c7da05 100644 --- a/app/Http/Controllers/RoundController.php +++ b/app/Http/Controllers/RoundController.php @@ -16,7 +16,7 @@ class RoundController extends Controller public function reset(Request $request, Room $room): Response { - $this->ensureModerator($request, $room); + $this->ensureCanManageRounds($request, $room); $this->rounds->reset($room); return response()->noContent(); @@ -24,7 +24,7 @@ class RoundController extends Controller public function reveal(Request $request, Room $room): Response { - $this->ensureModerator($request, $room); + $this->ensureCanManageRounds($request, $room); $this->rounds->reveal($room); return response()->noContent(); @@ -32,20 +32,20 @@ class RoundController extends Controller public function nextRound(Request $request, Room $room): Response { - $this->ensureModerator($request, $room); + $this->ensureCanManageRounds($request, $room); $this->rounds->startNext($room); return response()->noContent(); } - private function ensureModerator(Request $request, Room $room): void + private function ensureCanManageRounds(Request $request, Room $room): void { $player = $request->attributes->get('currentPlayer'); abort_unless( $player instanceof Player && $player->room_id === $room->id - && $player->is_moderator, + && ($player->is_moderator || $room->allow_participants_to_manage_rounds), 403, ); } diff --git a/app/Http/Requests/ShowEmbeddedRoomRequest.php b/app/Http/Requests/ShowEmbeddedRoomRequest.php new file mode 100644 index 0000000..fe463b8 --- /dev/null +++ b/app/Http/Requests/ShowEmbeddedRoomRequest.php @@ -0,0 +1,110 @@ +user() !== null; + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'join_code' => $this->string('join_code')->trim()->upper()->toString(), + 'player_name' => $this->string('player_name')->trim()->toString(), + ]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'join_code' => [ + 'bail', + 'required', + 'string', + 'size:6', + Rule::exists(Room::class, 'join_code')->whereNull('deleted_at'), + ], + 'player_name' => ['required', 'string', 'max:50'], + ]; + } + + /** @return array */ + public function after(): array + { + return [ + function (Validator $validator): void { + if ($validator->errors()->has('join_code')) { + return; + } + + $room = Room::query() + ->where('join_code', $this->input('join_code')) + ->first(); + + if ($room?->isClosed()) { + $validator->errors()->add('join_code', 'Dieser Raum ist geschlossen.'); + + return; + } + + if ($room === null || $this->user() === null) { + return; + } + + $nameIsTaken = Player::query() + ->where('room_id', $room->id) + ->where('name', $this->input('player_name')) + ->where(function ($query): void { + $query->whereNull('user_id') + ->orWhere('user_id', '!=', $this->user()->id); + }) + ->exists(); + + if ($nameIsTaken) { + $validator->errors()->add( + 'player_name', + 'Dieser Anzeigename ist in dem Raum bereits vorhanden.', + ); + } + }, + ]; + } + + public function messages(): array + { + return [ + 'join_code.exists' => 'Dieser Raumcode existiert nicht.', + 'join_code.size' => 'Der Raumcode muss aus sechs Zeichen bestehen.', + 'player_name.required' => 'Bitte hinterlege in der Hub-App einen Anzeigenamen.', + ]; + } + + protected function failedValidation(Validator $validator): never + { + throw new HttpResponseException( + Inertia::render('embedded/Error', [ + 'message' => $validator->errors()->first(), + ])->toResponse($this)->setStatusCode(422), + ); + } +} diff --git a/app/Http/Requests/UpdateRoomSettingsRequest.php b/app/Http/Requests/UpdateRoomSettingsRequest.php new file mode 100644 index 0000000..e41b0b9 --- /dev/null +++ b/app/Http/Requests/UpdateRoomSettingsRequest.php @@ -0,0 +1,33 @@ +route('room'); + + return $room instanceof Room + && $this->user()?->id === $room->owner_id; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array> + */ + public function rules(): array + { + return [ + 'allow_participants_to_manage_rounds' => ['required', 'boolean'], + 'is_persistent' => ['required', 'boolean'], + ]; + } +} diff --git a/app/Http/Resources/RoomResource.php b/app/Http/Resources/RoomResource.php index e2a99c7..ee3af66 100644 --- a/app/Http/Resources/RoomResource.php +++ b/app/Http/Resources/RoomResource.php @@ -26,6 +26,8 @@ class RoomResource extends JsonResource 'current_round' => $room->current_round, 'revealed' => $room->revealed_at !== null, 'state_version' => $room->state_version, + 'allow_participants_to_manage_rounds' => $room->allow_participants_to_manage_rounds, + 'is_persistent' => $room->is_persistent, 'statistics' => $pageData->statistics, 'deck' => (new RoomDeckResource($room->deck_config))->resolve($request), 'players' => $room->players diff --git a/app/Jobs/CloseInactiveRooms.php b/app/Jobs/CloseInactiveRooms.php index 6dcd355..acc2406 100644 --- a/app/Jobs/CloseInactiveRooms.php +++ b/app/Jobs/CloseInactiveRooms.php @@ -21,6 +21,7 @@ class CloseInactiveRooms implements ShouldQueue Room::query() ->whereNull('closed_at') + ->where('is_persistent', false) ->where(function ($query) use ($guestCutoff, $accountCutoff): void { $query ->where(function ($query) use ($guestCutoff): void { @@ -40,7 +41,12 @@ class CloseInactiveRooms implements ShouldQueue ->lockForUpdate() ->find((string) $roomId); - if ($room === null || $room->isClosed() || $room->last_activity_at === null) { + if ( + $room === null + || $room->isClosed() + || $room->is_persistent + || $room->last_activity_at === null + ) { return null; } diff --git a/app/Models/Room.php b/app/Models/Room.php index be345ad..32d3a75 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -22,6 +22,8 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property CarbonImmutable|null $last_activity_at * @property CarbonImmutable|null $closed_at * @property CarbonImmutable|null $deleted_at + * @property bool $allow_participants_to_manage_rounds + * @property bool $is_persistent * @property int $players_count */ class Room extends Model @@ -37,6 +39,13 @@ class Room extends Model 'state_version', 'last_activity_at', 'closed_at', + 'allow_participants_to_manage_rounds', + 'is_persistent', + ]; + + protected $attributes = [ + 'allow_participants_to_manage_rounds' => false, + 'is_persistent' => false, ]; protected function casts(): array @@ -48,6 +57,8 @@ class Room extends Model 'last_activity_at' => 'immutable_datetime', 'closed_at' => 'immutable_datetime', 'deck_config' => 'array', + 'allow_participants_to_manage_rounds' => 'boolean', + 'is_persistent' => 'boolean', ]; } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 24cf595..c87641d 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -51,6 +51,7 @@ class FortifyServiceProvider extends ServiceProvider Fortify::loginView(fn (Request $request) => Inertia::render('auth/Login', [ 'canResetPassword' => Features::enabled(Features::resetPasswords()), 'status' => $request->session()->get('status'), + 'rememberByDefault' => $request->session()->get('embedded_login', false), ])); Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/ResetPassword', [ diff --git a/app/Services/Rooms/RoomParticipationService.php b/app/Services/Rooms/RoomParticipationService.php index 0805efb..81958ca 100644 --- a/app/Services/Rooms/RoomParticipationService.php +++ b/app/Services/Rooms/RoomParticipationService.php @@ -101,4 +101,82 @@ class RoomParticipationService return [$room, $player]; } + + public function joinEmbedded(Room $room, User $user, string $playerName): Player + { + [$player, $change, $stateVersion] = DB::transaction(function () use ($room, $user, $playerName): array { + $lockedRoom = Room::query() + ->lockForUpdate() + ->findOrFail($room->id); + + if ($lockedRoom->isClosed()) { + throw ValidationException::withMessages([ + 'join_code' => 'Dieser Raum ist geschlossen.', + ]); + } + + $nameIsTaken = $lockedRoom->players() + ->where('name', $playerName) + ->where(function ($query) use ($user): void { + $query->whereNull('user_id') + ->orWhere('user_id', '!=', $user->id); + }) + ->exists(); + + if ($nameIsTaken) { + throw ValidationException::withMessages([ + 'player_name' => 'Dieser Anzeigename ist in dem Raum bereits vorhanden.', + ]); + } + + $player = $lockedRoom->players() + ->where('user_id', $user->id) + ->first(); + $change = null; + + if ($player === null) { + $player = $lockedRoom->players()->create([ + 'user_id' => $user->id, + 'name' => $playerName, + 'is_moderator' => false, + 'last_seen_at' => now(), + ]); + $change = RoomStateChange::PlayerJoined; + } else { + if ($player->inactive_at !== null) { + $change = RoomStateChange::PlayerJoined; + } elseif ($player->name !== $playerName) { + $change = RoomStateChange::PlayerUpdated; + } + + $player->update([ + 'name' => $playerName, + 'last_seen_at' => now(), + 'inactive_at' => null, + ]); + } + + if ($change === null) { + return [$player, null, null]; + } + + $lockedRoom->update([ + 'last_activity_at' => now(), + 'state_version' => $lockedRoom->state_version + 1, + ]); + + return [$player, $change, $lockedRoom->state_version]; + }); + + if ($change instanceof RoomStateChange && is_int($stateVersion)) { + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + $change, + $this->realtimeState->for($room), + ); + } + + return $player; + } } diff --git a/app/Services/Rooms/RoomRealtimeStateService.php b/app/Services/Rooms/RoomRealtimeStateService.php index 7871845..2b120f6 100644 --- a/app/Services/Rooms/RoomRealtimeStateService.php +++ b/app/Services/Rooms/RoomRealtimeStateService.php @@ -15,6 +15,7 @@ class RoomRealtimeStateService * current_round: int, * revealed: bool, * state_version: int, + * allow_participants_to_manage_rounds: bool, * statistics: array{average: string|null, median: string|null}|null, * players: array $room->current_round, 'revealed' => $room->revealed_at !== null, 'state_version' => $room->state_version, + 'allow_participants_to_manage_rounds' => $room->allow_participants_to_manage_rounds, 'statistics' => $this->statistics->currentRound($room), 'players' => $room->players ->map(function ($player) use ($room): array { diff --git a/app/Services/Rooms/RoomSettingsService.php b/app/Services/Rooms/RoomSettingsService.php new file mode 100644 index 0000000..e97aa36 --- /dev/null +++ b/app/Services/Rooms/RoomSettingsService.php @@ -0,0 +1,50 @@ +lockForUpdate() + ->findOrFail($room->id); + + abort_unless($lockedRoom->owner_id === $user->id, 403); + + $lockedRoom->fill($settings); + + if (! $lockedRoom->isDirty()) { + return null; + } + + $lockedRoom->state_version++; + $lockedRoom->save(); + + return $lockedRoom->state_version; + }); + + if ($stateVersion === null) { + return; + } + + RoomStateChanged::dispatch( + $room->id, + $stateVersion, + RoomStateChange::RoomSettingsUpdated, + $this->realtimeState->for($room), + ); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 409954c..e6cbc5e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -18,6 +18,13 @@ return Application::configure(basePath: dirname(__DIR__)) ) ->withMiddleware(function (Middleware $middleware): void { $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->alias([ 'room.player' => EnsureRoomParticipant::class, diff --git a/database/migrations/2026_09_01_134545_add_embedded_settings_to_rooms_table.php b/database/migrations/2026_09_01_134545_add_embedded_settings_to_rooms_table.php new file mode 100644 index 0000000..e8a2987 --- /dev/null +++ b/database/migrations/2026_09_01_134545_add_embedded_settings_to_rooms_table.php @@ -0,0 +1,32 @@ +boolean('allow_participants_to_manage_rounds')->default(false); + $table->boolean('is_persistent')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('rooms', function (Blueprint $table) { + $table->dropColumn([ + 'allow_participants_to_manage_rounds', + 'is_persistent', + ]); + }); + } +}; diff --git a/resources/js/app.ts b/resources/js/app.ts index 0dfec4b..6331335 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -9,8 +9,8 @@ const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), - layout: () => { - return PublicLayout; + layout: (name) => { + return name.startsWith('embedded/') ? null : PublicLayout; }, progress: { diff --git a/resources/js/components/MyRooms.vue b/resources/js/components/MyRooms.vue index 98243ce..74c5786 100644 --- a/resources/js/components/MyRooms.vue +++ b/resources/js/components/MyRooms.vue @@ -1,6 +1,6 @@ + + diff --git a/resources/js/components/rooms/RoomHeader.vue b/resources/js/components/rooms/RoomHeader.vue index 5dea09c..dc87aa3 100644 --- a/resources/js/components/rooms/RoomHeader.vue +++ b/resources/js/components/rooms/RoomHeader.vue @@ -1,9 +1,10 @@ @@ -85,7 +86,12 @@ defineProps<{
diff --git a/resources/js/pages/embedded/Error.vue b/resources/js/pages/embedded/Error.vue new file mode 100644 index 0000000..7bb4522 --- /dev/null +++ b/resources/js/pages/embedded/Error.vue @@ -0,0 +1,28 @@ + + + diff --git a/resources/js/pages/embedded/Room.vue b/resources/js/pages/embedded/Room.vue new file mode 100644 index 0000000..ab466c9 --- /dev/null +++ b/resources/js/pages/embedded/Room.vue @@ -0,0 +1,14 @@ + + + diff --git a/resources/js/pages/rooms/Settings.vue b/resources/js/pages/rooms/Settings.vue new file mode 100644 index 0000000..8e9a0b9 --- /dev/null +++ b/resources/js/pages/rooms/Settings.vue @@ -0,0 +1,103 @@ + + + diff --git a/resources/js/pages/rooms/Show.vue b/resources/js/pages/rooms/Show.vue index 9c56e18..2b543db 100644 --- a/resources/js/pages/rooms/Show.vue +++ b/resources/js/pages/rooms/Show.vue @@ -1,63 +1,10 @@ diff --git a/resources/js/types/room.ts b/resources/js/types/room.ts index e8f9381..7bdb6be 100644 --- a/resources/js/types/room.ts +++ b/resources/js/types/room.ts @@ -44,6 +44,8 @@ export type PokerRoom = { current_round: number; revealed: boolean; state_version: number; + allow_participants_to_manage_rounds: boolean; + is_persistent: boolean; statistics: RoomStatistics | null; deck: RoomDeck; players: RoomPlayer[]; @@ -64,6 +66,23 @@ export type RoomPageProps = { currentPlayer: CurrentRoomPlayer; }; +export type RoomExperienceProps = RoomPageProps & { + embedded?: boolean; +}; + +export type RoomSettings = Pick< + PokerRoom, + | 'id' + | 'title' + | 'join_code' + | 'allow_participants_to_manage_rounds' + | 'is_persistent' +>; + +export type RoomSettingsPageProps = { + room: RoomSettings; +}; + export type CreateRoomPageProps = { decks: SelectableDeck[]; defaultPlayerName: string | null; @@ -95,6 +114,8 @@ export type RoomHeaderProps = Pick< > & { canFavorite: boolean; presenterMode: boolean; + embedded: boolean; + playerName: string; }; export type RoomHeaderEmits = { @@ -138,13 +159,15 @@ export type RoomVoteDeckEmits = { export type RoomStateChange = | 'player_joined' + | 'player_updated' | 'vote_changed' | 'cards_revealed' | 'votes_reset' | 'next_round' | 'room_reopened' | 'room_closed' - | 'room_deleted'; + | 'room_deleted' + | 'room_settings_updated'; export type RoomStateChangedPayload = { version: number; @@ -157,6 +180,7 @@ export type RoomRealtimeState = Pick< | 'current_round' | 'revealed' | 'state_version' + | 'allow_participants_to_manage_rounds' | 'statistics' | 'players' >; diff --git a/routes/room.php b/routes/room.php index 48ef144..bcebf9d 100644 --- a/routes/room.php +++ b/routes/room.php @@ -1,11 +1,14 @@ name('room.code Route::post('/room', [RoomController::class, 'store'])->name('room.store'); Route::middleware(['auth', 'verified'])->group(function () { + Route::get('/embedded/scrum-poker', EmbeddedRoomController::class) + ->name('embedded.room'); +}); + +Route::post('/embedded/scrum-poker/logout', EmbeddedLogoutController::class) + ->middleware('auth') + ->name('embedded.logout'); + +Route::middleware(['auth', 'verified'])->group(function () { + Route::get('/rooms/{room}/settings', [RoomSettingsController::class, 'edit']) + ->name('room.settings.edit'); + Route::patch('/rooms/{room}/settings', [RoomSettingsController::class, 'update']) + ->name('room.settings.update'); Route::post('/rooms/{room}/reopen', [RoomLifecycleController::class, 'reopen']) ->name('room.reopen'); Route::delete('/rooms/{room}', [RoomLifecycleController::class, 'destroy']) diff --git a/tests/Feature/EmbeddedRoomTest.php b/tests/Feature/EmbeddedRoomTest.php new file mode 100644 index 0000000..8db7c62 --- /dev/null +++ b/tests/Feature/EmbeddedRoomTest.php @@ -0,0 +1,129 @@ + 'Fillibri Planning', + 'deck_config' => [ + 'name' => 'Fibonacci', + 'cards' => [ + ['key' => '3', 'label' => '3'], + ['key' => '5', 'label' => '5'], + ], + ], + ...$attributes, + ]); +} + +test('embedded room access sends guests through login and remembers the intended room', function () { + $room = createEmbeddedRoom(); + $url = route('embedded.room', [ + 'join_code' => $room->join_code, + 'player_name' => 'Nico', + ]); + + $this->get($url) + ->assertRedirect(route('login')) + ->assertSessionHas('embedded_login', true) + ->assertSessionHas('url.intended', $url); + + $this->get(route('login')) + ->assertInertia(fn (Assert $page) => $page + ->component('auth/Login') + ->where('rememberByDefault', true)); +}); + +test('an authenticated user automatically joins the embedded room', function () { + $user = User::factory()->create(); + $room = createEmbeddedRoom(['allow_participants_to_manage_rounds' => true]); + + $this->actingAs($user) + ->get(route('embedded.room', [ + 'join_code' => strtolower($room->join_code), + 'player_name' => ' Nico ', + ])) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('embedded/Room') + ->where('room.id', $room->id) + ->where('room.allow_participants_to_manage_rounds', true) + ->where('currentPlayer.name', 'Nico')) + ->assertSessionHas("room_players.{$room->id}"); + + $this->assertDatabaseHas('players', [ + 'room_id' => $room->id, + 'user_id' => $user->id, + 'name' => 'Nico', + 'is_moderator' => false, + ]); +}); + +test('embedded access reuses the user player and applies a changed display name', function () { + $user = User::factory()->create(); + $room = createEmbeddedRoom(); + $player = $room->players()->create([ + 'user_id' => $user->id, + 'name' => 'Old name', + 'inactive_at' => now(), + ]); + + $this->actingAs($user) + ->get(route('embedded.room', [ + 'join_code' => $room->join_code, + 'player_name' => 'New name', + ])) + ->assertOk(); + + expect($room->players()->count())->toBe(1) + ->and($player->fresh()->name)->toBe('New name') + ->and($player->fresh()->inactive_at)->toBeNull(); +}); + +test('embedded access shows a dedicated error when the display name is taken', function () { + $user = User::factory()->create(); + $room = createEmbeddedRoom(); + $room->players()->create(['name' => 'Nico']); + + $this->actingAs($user) + ->get(route('embedded.room', [ + 'join_code' => $room->join_code, + 'player_name' => 'Nico', + ])) + ->assertUnprocessable() + ->assertInertia(fn (Assert $page) => $page + ->component('embedded/Error') + ->where('message', 'Dieser Anzeigename ist in dem Raum bereits vorhanden.')); +}); + +test('embedded access shows a dedicated error for an unknown room code', function () { + $this->actingAs(User::factory()->create()) + ->get(route('embedded.room', [ + 'join_code' => 'ABC234', + 'player_name' => 'Nico', + ])) + ->assertUnprocessable() + ->assertInertia(fn (Assert $page) => $page + ->component('embedded/Error') + ->where('message', 'Dieser Raumcode existiert nicht.')); +}); + +test('embedded logout returns to the same configured room and signs the user out', function () { + $user = User::factory()->create(); + $room = createEmbeddedRoom(); + + $this->actingAs($user) + ->post(route('embedded.logout', [ + 'join_code' => $room->join_code, + 'player_name' => 'Nico', + ])) + ->assertRedirect(route('embedded.room', [ + 'join_code' => $room->join_code, + 'player_name' => 'Nico', + ])); + + $this->assertGuest(); +}); diff --git a/tests/Feature/RoomSettingsTest.php b/tests/Feature/RoomSettingsTest.php new file mode 100644 index 0000000..17199b3 --- /dev/null +++ b/tests/Feature/RoomSettingsTest.php @@ -0,0 +1,158 @@ + 'Teamraum', + 'deck_config' => [ + 'name' => 'Fibonacci', + 'cards' => [ + ['key' => '3', 'label' => '3'], + ['key' => '5', 'label' => '5'], + ], + ], + ...$attributes, + ]); +} + +test('room settings default to moderator control and normal retention', function () { + $room = createSettingsRoom(); + + expect($room->allow_participants_to_manage_rounds)->toBeFalse() + ->and($room->is_persistent)->toBeFalse(); +}); + +test('the owner can view and update room settings', function () { + $owner = User::factory()->create(); + $room = createSettingsRoom(['owner_id' => $owner->id]); + + $this->actingAs($owner) + ->get(route('room.settings.edit', $room)) + ->assertInertia(fn (Assert $page) => $page + ->component('rooms/Settings') + ->where('room.id', $room->id) + ->where('room.allow_participants_to_manage_rounds', false) + ->where('room.is_persistent', false)); + + Event::fake(); + + $this->actingAs($owner) + ->patch(route('room.settings.update', $room), [ + 'allow_participants_to_manage_rounds' => true, + 'is_persistent' => true, + ]) + ->assertRedirect(); + + $room->refresh(); + + expect($room->allow_participants_to_manage_rounds)->toBeTrue() + ->and($room->is_persistent)->toBeTrue() + ->and($room->state_version)->toBe(2); + + Event::assertDispatched( + RoomStateChanged::class, + fn (RoomStateChanged $event): bool => $event->change === RoomStateChange::RoomSettingsUpdated + && $event->state['allow_participants_to_manage_rounds'] === true, + ); +}); + +test('a user who does not own a room cannot manage its settings', function () { + $owner = User::factory()->create(); + $otherUser = User::factory()->create(); + $room = createSettingsRoom(['owner_id' => $owner->id]); + + $this->actingAs($otherUser) + ->get(route('room.settings.edit', $room)) + ->assertForbidden(); + + $this->actingAs($otherUser) + ->patch(route('room.settings.update', $room), [ + 'allow_participants_to_manage_rounds' => true, + 'is_persistent' => true, + ]) + ->assertForbidden(); + + expect($room->fresh()->allow_participants_to_manage_rounds)->toBeFalse(); +}); + +test('all active participants can manage rounds when the room setting is enabled', function () { + $room = createSettingsRoom(['allow_participants_to_manage_rounds' => true]); + $player = $room->players()->create([ + 'name' => 'Anna', + 'is_moderator' => false, + ]); + + $this->withSession(["room_players.{$room->id}" => $player->id]) + ->post(route('room.reveal', $room)) + ->assertNoContent(); + + expect($room->fresh()->revealed_at)->not->toBeNull(); +}); + +test('normal participants remain unable to manage rounds when the setting is disabled', function () { + $room = createSettingsRoom(); + $player = $room->players()->create([ + 'name' => 'Anna', + 'is_moderator' => false, + ]); + + $this->withSession(["room_players.{$room->id}" => $player->id]) + ->post(route('room.reveal', $room)) + ->assertForbidden(); +}); + +test('persistent rooms are excluded from automatic closing', function () { + $persistentRoom = createSettingsRoom([ + 'owner_id' => User::factory()->create()->id, + 'is_persistent' => true, + 'last_activity_at' => now()->subDays(60), + ]); + $expiringRoom = createSettingsRoom([ + 'owner_id' => User::factory()->create()->id, + 'last_activity_at' => now()->subDays(60), + ]); + + (new CloseInactiveRooms)->handle(); + + expect($persistentRoom->fresh()->closed_at)->toBeNull() + ->and($expiringRoom->fresh()->closed_at)->not->toBeNull(); +}); + +test('room settings broadcasts never expose hidden vote values', function () { + $owner = User::factory()->create(); + $room = createSettingsRoom(['owner_id' => $owner->id]); + $player = $room->players()->create([ + 'name' => 'Anna', + 'is_moderator' => false, + ]); + $player->votes()->create([ + 'room_id' => $room->id, + 'round' => 1, + 'value' => '5', + ]); + + Event::fake(); + + $this->actingAs($owner)->patch(route('room.settings.update', $room), [ + 'allow_participants_to_manage_rounds' => true, + 'is_persistent' => false, + ]); + + Event::assertDispatched( + RoomStateChanged::class, + function (RoomStateChanged $event) use ($player): bool { + $broadcastPlayer = collect($event->state['players'])->firstWhere('id', $player->id); + + return $broadcastPlayer['has_voted'] === true + && $broadcastPlayer['vote'] === null; + }, + ); +}); From e30d2e61aa4777af2864b8da22b8aa83d2a19016 Mon Sep 17 00:00:00 2001 From: Twyco Date: Thu, 3 Sep 2026 11:18:49 +0200 Subject: [PATCH 2/2] get color from app --- .../js/components/rooms/RoomExperience.vue | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/resources/js/components/rooms/RoomExperience.vue b/resources/js/components/rooms/RoomExperience.vue index aed0935..0376512 100644 --- a/resources/js/components/rooms/RoomExperience.vue +++ b/resources/js/components/rooms/RoomExperience.vue @@ -16,6 +16,20 @@ const props = withDefaults(defineProps(), { const page = usePage(); const presenterMode = ref(false); const roomNotice = computed(() => page.props.roomNotice); +const embeddedBackgroundColor = computed(() => { + if (!props.embedded) { + return undefined; + } + + const backgroundColor = new URL( + page.url, + 'https://embedded.invalid', + ).searchParams.get('background_color'); + + return backgroundColor && /^#[0-9a-f]{6}$/i.test(backgroundColor) + ? backgroundColor + : undefined; +}); const canFavorite = computed( () => !props.embedded && page.props.auth.user?.id != props.room.owner_id, ); @@ -35,6 +49,11 @@ const {