Merge remote-tracking branch 'origin/next' into fix/trust-hosts-url-generation

This commit is contained in:
Andras Bacsai 2026-03-27 14:14:36 +01:00
commit ba6f0cdb38
4 changed files with 226 additions and 35 deletions

View file

@ -108,9 +108,31 @@ public function link()
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
public function showInvitation()
{
$invitationUuid = request()->route('uuid');
$invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail();
$user = User::whereEmail($invitation->email)->firstOrFail();
if (Auth::id() !== $user->id) {
abort(400, 'You are not allowed to accept this invitation.');
}
if (! $invitation->isValid()) {
abort(400, 'Invitation expired.');
}
$alreadyMember = $user->teams()->where('team_id', $invitation->team->id)->exists();
return view('invitation.accept', [
'invitation' => $invitation,
'team' => $invitation->team,
'alreadyMember' => $alreadyMember,
]);
}
public function acceptInvitation()
{
$resetPassword = request()->query('reset-password');
$invitationUuid = request()->route('uuid');
$invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail();
@ -119,43 +141,21 @@ public function acceptInvitation()
if (Auth::id() !== $user->id) {
abort(400, 'You are not allowed to accept this invitation.');
}
$invitationValid = $invitation->isValid();
if ($invitationValid) {
if ($resetPassword) {
$user->update([
'password' => Hash::make($invitationUuid),
'force_password_reset' => true,
]);
}
if ($user->teams()->where('team_id', $invitation->team->id)->exists()) {
$invitation->delete();
return redirect()->route('team.index');
}
$user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
$invitation->delete();
refreshSession($invitation->team);
return redirect()->route('team.index');
} else {
if (! $invitation->isValid()) {
abort(400, 'Invitation expired.');
}
}
public function revokeInvitation()
{
$invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail();
$user = User::whereEmail($invitation->email)->firstOrFail();
if (is_null(Auth::user())) {
return redirect()->route('login');
}
if (Auth::id() !== $user->id) {
abort(401);
if ($user->teams()->where('team_id', $invitation->team->id)->exists()) {
$invitation->delete();
return redirect()->route('team.index');
}
$user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
$invitation->delete();
refreshSession($invitation->team);
return redirect()->route('team.index');
}
}

View file

@ -0,0 +1,43 @@
<x-layout-simple>
<section class="bg-gray-50 dark:bg-base">
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
</div>
<div class="space-y-6">
<div class="p-6 rounded-lg border border-neutral-500/20 bg-white/5">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Team Invitation</h2>
<p class="text-sm text-gray-600 dark:text-neutral-400 mb-2">
You have been invited to join:
</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{{ $team->name }}
</p>
<p class="text-sm text-gray-600 dark:text-neutral-400 mb-1">
Role: <span class="font-medium text-gray-900 dark:text-white">{{ ucfirst($invitation->role) }}</span>
</p>
@if ($alreadyMember)
<div class="mt-4 p-3 bg-warning/10 border border-warning rounded-lg">
<p class="text-sm text-warning">You are already a member of this team.</p>
</div>
@endif
<form method="POST" action="{{ route('team.invitation.accept', $invitation->uuid) }}" class="mt-6">
@csrf
<x-forms.button class="w-full justify-center py-3 box-boarding" type="submit" isHighlighted>
{{ $alreadyMember ? 'Dismiss Invitation' : 'Accept Invitation' }}
</x-forms.button>
</form>
</div>
</div>
</div>
</div>
</section>
</x-layout-simple>

View file

@ -84,6 +84,7 @@
use App\Livewire\Team\Member\Index as TeamMemberIndex;
use App\Livewire\Terminal\Index as TerminalIndex;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ServiceDatabase;
use App\Providers\RouteServiceProvider;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
@ -192,8 +193,8 @@
})->name('terminal.auth.ips')->middleware('can.access.terminal');
Route::prefix('invitations')->group(function () {
Route::get('/{uuid}', [Controller::class, 'acceptInvitation'])->name('team.invitation.accept');
Route::get('/{uuid}/revoke', [Controller::class, 'revokeInvitation'])->name('team.invitation.revoke');
Route::get('/{uuid}', [Controller::class, 'showInvitation'])->name('team.invitation.show');
Route::post('/{uuid}', [Controller::class, 'acceptInvitation'])->name('team.invitation.accept');
});
Route::get('/projects', ProjectIndex::class)->name('project.index');
@ -344,7 +345,7 @@
}
}
$filename = data_get($execution, 'filename');
if ($execution->scheduledDatabaseBackup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
if ($execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class) {
$server = $execution->scheduledDatabaseBackup->database->service->destination->server;
} else {
$server = $execution->scheduledDatabaseBackup->database->destination->server;
@ -385,7 +386,7 @@
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="'.basename($filename).'"',
]);
} catch (\Throwable $e) {
} catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 500);
}
})->name('download.backup');

View file

@ -0,0 +1,147 @@
<?php
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create(['email' => 'invited@example.com']);
$this->invitation = TeamInvitation::create([
'team_id' => $this->team->id,
'uuid' => 'test-invitation-uuid',
'email' => 'invited@example.com',
'role' => 'member',
'link' => url('/invitations/test-invitation-uuid'),
'via' => 'link',
]);
});
test('GET invitation shows landing page without accepting', function () {
$this->actingAs($this->user);
$response = $this->get('/invitations/test-invitation-uuid');
$response->assertStatus(200);
$response->assertViewIs('invitation.accept');
$response->assertSee($this->team->name);
$response->assertSee('Accept Invitation');
// Invitation should NOT be deleted (not accepted yet)
$this->assertDatabaseHas('team_invitations', [
'uuid' => 'test-invitation-uuid',
]);
// User should NOT be added to the team
expect($this->user->teams()->where('team_id', $this->team->id)->exists())->toBeFalse();
});
test('GET invitation with reset-password query param does not reset password', function () {
$this->actingAs($this->user);
$originalPassword = $this->user->password;
$response = $this->get('/invitations/test-invitation-uuid?reset-password=1');
$response->assertStatus(200);
// Password should NOT be changed
$this->user->refresh();
expect($this->user->password)->toBe($originalPassword);
// Invitation should NOT be accepted
$this->assertDatabaseHas('team_invitations', [
'uuid' => 'test-invitation-uuid',
]);
});
test('POST invitation accepts and adds user to team', function () {
$this->actingAs($this->user);
$response = $this->post('/invitations/test-invitation-uuid');
$response->assertRedirect(route('team.index'));
// Invitation should be deleted
$this->assertDatabaseMissing('team_invitations', [
'uuid' => 'test-invitation-uuid',
]);
// User should be added to the team
expect($this->user->teams()->where('team_id', $this->team->id)->exists())->toBeTrue();
});
test('POST invitation without CSRF token is rejected', function () {
$this->actingAs($this->user);
$response = $this->withoutMiddleware(EncryptCookies::class)
->post('/invitations/test-invitation-uuid', [], [
'X-CSRF-TOKEN' => 'invalid-token',
]);
// Should be rejected with 419 (CSRF token mismatch)
$response->assertStatus(419);
// Invitation should NOT be accepted
$this->assertDatabaseHas('team_invitations', [
'uuid' => 'test-invitation-uuid',
]);
});
test('unauthenticated user cannot view invitation', function () {
$response = $this->get('/invitations/test-invitation-uuid');
$response->assertRedirect();
});
test('wrong user cannot view invitation', function () {
$otherUser = User::factory()->create(['email' => 'other@example.com']);
$this->actingAs($otherUser);
$response = $this->get('/invitations/test-invitation-uuid');
$response->assertStatus(400);
});
test('wrong user cannot accept invitation via POST', function () {
$otherUser = User::factory()->create(['email' => 'other@example.com']);
$this->actingAs($otherUser);
$response = $this->post('/invitations/test-invitation-uuid');
$response->assertStatus(400);
// Invitation should still exist
$this->assertDatabaseHas('team_invitations', [
'uuid' => 'test-invitation-uuid',
]);
});
test('GET revoke route no longer exists', function () {
$this->actingAs($this->user);
$response = $this->get('/invitations/test-invitation-uuid/revoke');
$response->assertStatus(404);
});
test('POST invitation for already-member user deletes invitation without duplicating', function () {
$this->user->teams()->attach($this->team->id, ['role' => 'member']);
$this->actingAs($this->user);
$response = $this->post('/invitations/test-invitation-uuid');
$response->assertRedirect(route('team.index'));
// Invitation should be deleted
$this->assertDatabaseMissing('team_invitations', [
'uuid' => 'test-invitation-uuid',
]);
// User should still have exactly one membership in this team
expect($this->user->teams()->where('team_id', $this->team->id)->count())->toBe(1);
});