Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot]
2026-10-05 21:48:09 +00:00
12 changed files with 429 additions and 28 deletions
+30 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\TeamInvitation;
use Illuminate\Support\Collection;
use Livewire\Component;
@@ -40,6 +41,34 @@ class Dashboard extends Component
public function render()
{
return view('livewire.dashboard');
return view('livewire.dashboard', [
'pendingInvitations' => $this->pendingInvitations(),
]);
}
/**
* Unexpired invitations for the user's email to teams the user has not joined yet.
*
* Uses hasExpired() instead of isValid() so rendering never deletes invitations or users.
*
* @return Collection<int, TeamInvitation>
*/
private function pendingInvitations(): Collection
{
$user = auth()->user();
if (! $user) {
return collect();
}
return TeamInvitation::query()
->where('email', strtolower($user->email))
->whereHas('team')
->whereNotIn('team_id', $user->teams()->select('teams.id'))
->with('team:id,name')
->oldest()
->get()
->reject(fn (TeamInvitation $invitation) => $invitation->hasExpired())
->values();
}
}
@@ -77,9 +77,11 @@ class Domains extends Component
public ?string $editingGeneratedHost = null;
/** @var array<int, array{url: string, service: ?string, dns_status: string, dns_message: string, expected_ip: ?string, checked_at?: ?string, is_suggested?: bool, suggested_for?: ?string, suggestion_label?: ?string, needs_force_add?: bool, internal_port?: ?int, has_port_override?: bool}> */
#[Locked]
public array $domainRows = [];
/** When set, the next addSuggestedDomain call for this index skips the DNS block. */
#[Locked]
public ?int $forceAddSuggestedIndex = null;
/** @var array<int, string> */
@@ -122,6 +124,7 @@ class Domains extends Component
public bool $isCheckingDns = false;
#[Locked]
public bool $dnsValidationEnabled = true;
/** Resolved or literal IP users should point DNS at. */
@@ -11,6 +11,7 @@ use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Locked;
use Livewire\Component;
class PreviewDomains extends Component
@@ -19,6 +20,8 @@ class PreviewDomains extends Component
public ApplicationPreview $preview;
/** @var array<int, array<string, mixed>> */
#[Locked]
public array $domainRows = [];
public array $newDomainParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
+1
View File
@@ -110,6 +110,7 @@ class Domains extends Component
public bool $isCheckingDns = false;
#[Locked]
public bool $dnsValidationEnabled = true;
#[Locked]
+37
View File
@@ -2,6 +2,7 @@
namespace App\Livewire\Team;
use App\Enums\Role;
use App\Models\User;
use Livewire\Component;
use Livewire\WithPagination;
@@ -79,6 +80,10 @@ class AdminView extends Component
return $this->dispatch('error', 'User not found');
}
if ($error = $this->deletionError(auth()->user(), $user)) {
return $this->dispatch('error', $error);
}
try {
$user->delete();
auditLog('ui.user.deleted', [
@@ -96,6 +101,38 @@ class AdminView extends Component
}
}
/**
* Instance admins may delete other users, but never the root user, their
* own account (use the profile instead), or a user whose role in the root
* team is higher than their own.
*/
private function deletionError(User $actor, User $target): ?string
{
if ($target->id === 0) {
return 'The root user cannot be deleted.';
}
if ($target->id === $actor->id) {
return 'Delete your own account from your profile.';
}
$actorRole = $this->rootTeamRole($actor);
$targetRole = $this->rootTeamRole($target);
if (! $actorRole || ($targetRole && $targetRole->gt($actorRole))) {
return 'You cannot delete a user with a higher role in the root team.';
}
return null;
}
private function rootTeamRole(User $user): ?Role
{
$role = $user->teams()->where('teams.id', 0)->first()?->pivot?->role;
return $role ? Role::tryFrom($role) : null;
}
public function render()
{
abort_unless(isInstanceAdmin(), 403);
+11 -23
View File
@@ -6,7 +6,6 @@ use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Models\OauthSetting;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
@@ -82,29 +81,18 @@ class FortifyServiceProvider extends ServiceProvider
$user->updated_at = now();
$user->save();
// Check if user has a pending invitation they haven't accepted yet
$invitation = TeamInvitation::whereEmail($email)->first();
if ($invitation && $invitation->isValid()) {
// User is logging in for the first time after being invited
// Attach them to the invited team if not already attached
if (! $user->teams()->where('team_id', $invitation->team->id)->exists()) {
$user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
}
$team = $invitation->team;
$invitation->delete();
session(['currentTeam' => $team]);
} else {
// Restore the last active team; only fall back when unambiguous.
$team = $user->resolveStoredTeam();
if (! $team && $user->teams->isEmpty()) {
$team = $user->recreate_personal_team();
}
if ($team) {
session(['currentTeam' => $team]);
}
// Otherwise (multiple teams, no stored choice) leave the session
// team unset so the user is sent to the team-selection screen.
// Pending team invitations are not accepted here; the user accepts
// them explicitly on the invitation page (team.invitation.show).
// Restore the last active team; only fall back when unambiguous.
$team = $user->resolveStoredTeam();
if (! $team && $user->teams->isEmpty()) {
$team = $user->recreate_personal_team();
}
if ($team) {
session(['currentTeam' => $team]);
}
// Otherwise (multiple teams, no stored choice) leave the session
// team unset so the user is sent to the team-selection screen.
return $user;
}
@@ -15,6 +15,23 @@
@endphp
<div class="flex min-w-0 flex-col gap-8">
@if ($pendingInvitations->isNotEmpty())
<div class="flex min-w-0 flex-col gap-2">
@foreach ($pendingInvitations as $invitation)
<x-callout type="info" title="Pending team invitation"
wire:key="dashboard-invitation-{{ $invitation->uuid }}">
<div class="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span class="min-w-0">Team <span class="font-semibold">{{ $invitation->team->name }}</span>
invited you as {{ ucfirst($invitation->role) }}.</span>
<a href="{{ route('team.invitation.show', $invitation->uuid) }}" class="button shrink-0">
Review invitation
</a>
</div>
</x-callout>
@endforeach
</div>
@endif
<livewire:dashboard.active-deployments />
@if ($hasTrafficAnalytics)
+102
View File
@@ -0,0 +1,102 @@
<?php
use App\Livewire\Team\AdminView;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->rootTeam = Team::factory()->create(['id' => 0]);
$this->rootUser = User::factory()->create(['id' => 0]);
$this->rootAdmin = User::factory()->create();
$this->rootTeam->members()->attach($this->rootAdmin->id, ['role' => 'admin']);
$this->rootOwner = User::factory()->create();
$this->rootTeam->members()->attach($this->rootOwner->id, ['role' => 'owner']);
});
function actAsRootTeamMemberForAdminViewDeletion(User $user): void
{
test()->actingAs($user);
session(['currentTeam' => Team::find(0)]);
}
test('a root team admin cannot delete the root user', function () {
actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin);
Livewire::test(AdminView::class)
->call('delete', 0, 'password')
->assertDispatched('error', 'The root user cannot be deleted.');
expect(User::find(0))->not->toBeNull();
});
test('a root team admin cannot delete a root team owner', function () {
actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin);
Livewire::test(AdminView::class)
->call('delete', $this->rootOwner->id, 'password')
->assertDispatched('error', 'You cannot delete a user with a higher role in the root team.');
expect(User::find($this->rootOwner->id))->not->toBeNull();
});
test('a root team owner cannot delete the root user', function () {
actAsRootTeamMemberForAdminViewDeletion($this->rootOwner);
Livewire::test(AdminView::class)
->call('delete', 0, 'password')
->assertDispatched('error', 'The root user cannot be deleted.');
expect(User::find(0))->not->toBeNull();
});
test('an instance admin cannot delete their own account from the admin view', function () {
actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin);
Livewire::test(AdminView::class)
->call('delete', $this->rootAdmin->id, 'password')
->assertDispatched('error', 'Delete your own account from your profile.');
expect(User::find($this->rootAdmin->id))->not->toBeNull();
});
test('a root team owner can delete a root team admin and a member', function () {
$member = User::factory()->create();
$this->rootTeam->members()->attach($member->id, ['role' => 'member']);
actAsRootTeamMemberForAdminViewDeletion($this->rootOwner);
Livewire::test(AdminView::class)
->call('delete', $this->rootAdmin->id, 'password')
->assertReturned(true)
->call('delete', $member->id, 'password')
->assertReturned(true);
expect(User::find($this->rootAdmin->id))->toBeNull()
->and(User::find($member->id))->toBeNull();
});
test('a root team admin can delete another root team admin and a user outside the root team', function () {
$otherAdmin = User::factory()->create();
$this->rootTeam->members()->attach($otherAdmin->id, ['role' => 'admin']);
$outsider = User::factory()->create();
actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin);
Livewire::test(AdminView::class)
->call('delete', $otherAdmin->id, 'password')
->assertReturned(true)
->call('delete', $outsider->id, 'password')
->assertReturned(true);
expect(User::find($otherAdmin->id))->toBeNull()
->and(User::find($outsider->id))->toBeNull();
});
@@ -0,0 +1,65 @@
<?php
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.store', 'array');
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->invitee = User::factory()->create(['email' => 'invitee@example.com']);
$this->personalTeam = $this->invitee->teams->first();
$this->personalTeam->update(['show_boarding' => false]);
$this->invitingTeam = Team::factory()->create(['show_boarding' => false]);
$this->invitation = TeamInvitation::create([
'team_id' => $this->invitingTeam->id,
'uuid' => 'pending-login-invitation',
'email' => $this->invitee->email,
'role' => 'admin',
'link' => route('team.invitation.show', ['uuid' => 'pending-login-invitation']),
'via' => 'email',
]);
});
it('does not accept a pending invitation on password login', function () {
$this->post('/login', ['email' => $this->invitee->email, 'password' => 'password'])
->assertRedirect();
$this->assertAuthenticatedAs($this->invitee);
expect($this->invitee->teams()->where('team_id', $this->invitingTeam->id)->exists())->toBeFalse()
->and(data_get(session('currentTeam'), 'id'))->toBe($this->personalTeam->id);
$this->assertDatabaseHas('team_invitations', ['id' => $this->invitation->id]);
});
it('lets the user accept the invitation explicitly after login', function () {
$this->post('/login', ['email' => $this->invitee->email, 'password' => 'password']);
$this->get(route('team.invitation.show', $this->invitation->uuid))
->assertSuccessful()
->assertSee($this->invitingTeam->name);
$this->post(route('team.invitation.accept', $this->invitation->uuid))
->assertRedirect(route('team.index'));
expect($this->invitee->teams()->where('team_id', $this->invitingTeam->id)->first()?->pivot->role)->toBe('admin')
->and(data_get(session('currentTeam'), 'id'))->toBe($this->invitingTeam->id);
$this->assertDatabaseMissing('team_invitations', ['id' => $this->invitation->id]);
});
it('returns to the invitation page after logging in from the invitation link', function () {
$this->get(route('team.invitation.show', $this->invitation->uuid))
->assertRedirect(route('login'));
$this->post('/login', ['email' => $this->invitee->email, 'password' => 'password'])
->assertRedirect(route('team.invitation.show', $this->invitation->uuid));
$this->get(route('team.invitation.show', $this->invitation->uuid))
->assertSuccessful()
->assertSee($this->invitingTeam->name);
});
@@ -0,0 +1,105 @@
<?php
use App\Livewire\Dashboard;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function createDashboardTestInvitation(Team $team, string $email, array $attributes = []): TeamInvitation
{
$uuid = $attributes['uuid'] ?? (string) str()->uuid();
return TeamInvitation::create(array_merge([
'team_id' => $team->id,
'uuid' => $uuid,
'email' => $email,
'role' => 'admin',
'link' => url('/invitations/'.$uuid),
'via' => 'link',
], $attributes));
}
beforeEach(function () {
Queue::fake();
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->user = User::factory()->create(['email' => 'invited@example.com']);
$this->ownTeam = Team::factory()->create();
$this->user->teams()->attach($this->ownTeam, ['role' => 'owner']);
$this->invitingTeam = Team::factory()->create(['name' => 'Acme Platform']);
Team::query()->update(['show_boarding' => false]);
$this->actingAs($this->user);
session(['currentTeam' => $this->ownTeam]);
});
it('shows a pending invitation for the user email with a review link', function () {
$invitation = createDashboardTestInvitation($this->invitingTeam, 'Invited@Example.com');
Livewire::test(Dashboard::class)
->assertSee('Acme Platform')
->assertSee('invited you as Admin.')
->assertSee('Review invitation')
->assertSee(route('team.invitation.show', $invitation->uuid), false);
});
it('does not show invitations for another email', function () {
$invitation = createDashboardTestInvitation($this->invitingTeam, 'someone-else@example.com');
Livewire::test(Dashboard::class)
->assertDontSee('Acme Platform')
->assertDontSee('Review invitation')
->assertDontSee(route('team.invitation.show', $invitation->uuid), false);
});
it('does not show expired invitations and keeps them in place', function () {
$invitation = createDashboardTestInvitation($this->invitingTeam, 'invited@example.com');
$invitation->forceFill([
'created_at' => now()->subDays(config('constants.invitation.link.expiration_days') + 1),
])->save();
Livewire::test(Dashboard::class)
->assertDontSee('Acme Platform')
->assertDontSee('Review invitation');
expect(TeamInvitation::query()->whereKey($invitation->id)->exists())->toBeTrue()
->and(User::query()->whereKey($this->user->id)->exists())->toBeTrue();
});
it('does not show invitations to a team the user already belongs to', function () {
$this->user->teams()->attach($this->invitingTeam, ['role' => 'member']);
createDashboardTestInvitation($this->invitingTeam, 'invited@example.com');
Livewire::test(Dashboard::class)
->assertDontSee('Acme Platform')
->assertDontSee('Review invitation');
});
it('no longer shows the invitation after it is accepted', function () {
$invitation = createDashboardTestInvitation($this->invitingTeam, 'invited@example.com');
$this->get(route('dashboard'))
->assertOk()
->assertSee(route('team.invitation.show', $invitation->uuid), false);
$this->post(route('team.invitation.accept', $invitation->uuid))
->assertRedirect(route('team.index'));
expect($this->user->teams()->whereKey($this->invitingTeam->id)->exists())->toBeTrue();
$this->actingAs($this->user->fresh());
session(['currentTeam' => $this->invitingTeam->fresh()]);
Livewire::test(Dashboard::class)
->assertDontSee('Acme Platform')
->assertDontSee('Review invitation')
->assertDontSee(route('team.invitation.show', $invitation->uuid), false);
});
@@ -1,7 +1,11 @@
<?php
use App\Livewire\Project\Application\Domains as ApplicationDomains;
use App\Livewire\Project\Application\PreviewDomains;
use App\Livewire\Project\Service\Domains;
use App\Livewire\Server\DockerImages;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
@@ -36,7 +40,7 @@ beforeEach(function () {
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
});
it('rejects a client update of the service domain rows', function () {
it('rejects a client update of the service domain state', function (string $property, mixed $value) {
$destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
@@ -59,8 +63,11 @@ it('rejects a client update of the service domain rows', function () {
Livewire::test(Domains::class, ['service' => $service->fresh(['applications', 'server'])])
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->all() === ['https://web.example.com'])
->set('domainRows', [['url' => 'https://attacker.example.com']]);
})->throws(CannotUpdateLockedPropertyException::class);
->set($property, $value);
})->with([
'domain rows' => ['domainRows', [['url' => 'https://attacker.example.com']]],
'dns validation' => ['dnsValidationEnabled', false],
])->throws(CannotUpdateLockedPropertyException::class);
it('rejects a client update of the docker image list', function () {
$imageId = 'sha256:'.str_repeat('a', 64);
@@ -76,3 +83,48 @@ it('rejects a client update of the docker image list', function () {
->assertSet('images.0.containers', ['web'])
->set('images.0.containers', []);
})->throws(CannotUpdateLockedPropertyException::class);
function createLockedDomainRowsApplication(Server $server, Team $team): Application
{
$destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
['server_id' => $server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
));
$environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]);
return Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'fqdn' => 'https://app.example.com',
'build_pack' => 'nixpacks',
'ports_exposes' => '3000',
]);
}
it('rejects a client update of the application domain rows', function (string $property, mixed $value) {
$application = createLockedDomainRowsApplication($this->server, $this->team);
Livewire::test(ApplicationDomains::class, ['application' => $application->fresh()])
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->where('is_suggested', false)->pluck('url')->all() === ['https://app.example.com'])
->set($property, $value);
})->with([
'domain rows' => ['domainRows', [['url' => 'https://app.example.com', 'service' => null, 'dns_status' => 'ok', 'dns_message' => 'faked', 'expected_ip' => null, 'needs_force_add' => true]]],
'one row flag' => ['domainRows.0.needs_force_add', true],
'forced suggestion index' => ['forceAddSuggestedIndex', 0],
'DNS validation flag' => ['dnsValidationEnabled', false],
])->throws(CannotUpdateLockedPropertyException::class);
it('rejects a client update of the preview domain rows', function () {
$application = createLockedDomainRowsApplication($this->server, $this->team);
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 7,
'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/7',
'fqdn' => 'https://pr-7.example.com',
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->assertSet('domainRows', fn (array $rows): bool => collect($rows)->pluck('url')->all() === ['https://pr-7.example.com'])
->set('domainRows.0.dns_status', 'ok');
})->throws(CannotUpdateLockedPropertyException::class);
-1
View File
@@ -1003,7 +1003,6 @@ it('shows an inherited internal port badge from the coolify service env port', f
Livewire::test(Domains::class, ['service' => $this->service->fresh(['applications', 'server'])])
->assertSet('domainRows.0.internal_port', 3000)
->assertSet('domainRows.0.has_port_override', false)
->set('dnsValidationEnabled', false)
->call('startEdit', 0)
->assertSet('editingDomainParts.port', '3000')
->call('updateDomain')