fix(coolify-access): prefer managed root team
All checks were successful
Build MapleDeploy Coolify Image / build (push) Successful in 41s

This commit is contained in:
rosslh 2026-06-14 14:39:05 -04:00
parent e3cb2675dd
commit 65d85fb890
8 changed files with 166 additions and 11 deletions

View file

@ -103,6 +103,7 @@ private function createMember(array $input): int
'password' => Hash::make($input['password']),
]);
$user->markEmailAsVerified();
$this->deletePersonalTeams($user);
$user->teams()->syncWithoutDetaching([
$rootTeam->id => ['role' => $input['team_role']],
]);
@ -113,6 +114,25 @@ private function createMember(array $input): int
return $this->succeedWithUser($user);
}
private function deletePersonalTeams(User $user): void
{
// MapleDeploy branding: dashboard-managed users should only see the
// managed instance root team, not an empty personal Coolify team.
$personalTeams = Team::query()
->where('teams.id', '!=', 0)
->where('personal_team', true)
->whereHas('members', fn ($query) => $query->whereKey($user->id))
->get();
foreach ($personalTeams as $team) {
DB::table('team_user')
->where('team_id', $team->id)
->where('user_id', $user->id)
->delete();
DB::table('teams')->where('id', $team->id)->delete();
}
}
private function readPassword(): string
{
return rtrim((string) stream_get_contents(STDIN), "\n");

View file

@ -2,6 +2,8 @@
namespace App\Console\Commands\Mapledeploy;
use App\Enums\Role;
use App\Models\Team;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@ -42,6 +44,13 @@ public function handle(): int
if (! $user) {
return $this->failWith('USER_NOT_FOUND');
}
$rootTeam = null;
if ((int) $user->id !== 0) {
$rootTeam = Team::find(0);
if (! $rootTeam) {
return $this->failWith('ROOT_TEAM_MISSING');
}
}
$changes = [
'password' => Hash::make($password),
@ -60,13 +69,23 @@ public function handle(): int
$changes['name'] = $input['name'];
}
$user->forceFill($changes)->save();
if ($updatesOwner && ! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
// MapleDeploy branding: password resets from the dashboard should end
// any browser sessions authenticated with the previous password.
DB::table('sessions')->where('user_id', $user->id)->delete();
DB::transaction(function () use ($user, $changes, $updatesOwner, $rootTeam) {
$user->forceFill($changes)->save();
if ($updatesOwner && ! $user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
}
if ($rootTeam) {
// MapleDeploy branding: matching an existing Coolify user by
// email must grant the same root-team admin access as a newly
// dashboard-created user.
$user->teams()->syncWithoutDetaching([
$rootTeam->id => ['role' => Role::ADMIN->value],
]);
}
// MapleDeploy branding: password resets from the dashboard should
// end browser sessions authenticated with the previous password.
DB::table('sessions')->where('user_id', $user->id)->delete();
});
$this->line(json_encode([
'user' => [

View file

@ -117,7 +117,9 @@ public function link()
$user->teams()->attach($team->id, ['role' => $invitation->first()->role]);
$invitation->delete();
} else {
$team = $user->teams()->first();
// MapleDeploy branding: root-team admins should land in
// the managed instance team, not their empty personal team.
$team = $user->mapledeployPreferredTeam();
}
Auth::login($user);
session(['currentTeam' => $team]);

View file

@ -16,6 +16,16 @@ public function handle(Request $request, Closure $next): Response
$currentTeam = auth()->user()?->recreate_personal_team();
refreshSession($currentTeam);
}
$preferredTeam = auth()?->user()?->mapledeployPreferredTeam();
if (
$preferredTeam &&
$preferredTeam->id === 0 &&
auth()?->user()?->currentTeam()?->id !== 0
) {
// MapleDeploy branding: repair sessions that landed in the empty
// personal team before dashboard-managed root-team access existed.
refreshSession($preferredTeam);
}
if (auth()?->user()?->currentTeam()) {
refreshSession(auth()->user()->currentTeam());
} elseif (auth()?->user()?->teams?->count() > 0) {

View file

@ -209,6 +209,20 @@ public function recreate_personal_team()
return $new_team;
}
public function mapledeployPreferredTeam(): ?Team
{
// MapleDeploy branding: dashboard-managed users are attached to the
// root team so they can administer the customer's managed instance.
$rootTeam = $this->teams->firstWhere('id', 0);
$rootRole = data_get($rootTeam, 'pivot.role');
if ($rootTeam && ($rootRole === 'admin' || $rootRole === 'owner')) {
return $rootTeam;
}
return $this->teams->firstWhere('personal_team', true)
?? $this->teams->first();
}
public function createToken(string $name, array $abilities = ['*'], ?DateTimeInterface $expiresAt = null)
{
$plainTextToken = sprintf(

View file

@ -96,8 +96,9 @@ public function boot(): void
$user->currentTeam = $invitation->team;
$invitation->delete();
} else {
// Normal login - use personal team
$user->currentTeam = $user->teams->firstWhere('personal_team', true);
// MapleDeploy branding: root-team admins should land in
// the managed instance team, not their empty personal team.
$user->currentTeam = $user->mapledeployPreferredTeam();
if (! $user->currentTeam) {
$user->currentTeam = $user->recreate_personal_team();
}

View file

@ -0,0 +1,65 @@
<?php
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.store' => 'array',
'cache.default' => 'array',
]);
});
test('MapleDeploy root team admins log in to the managed root team', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$rootTeam = Team::factory()->create([
'id' => 0,
'name' => 'Root Team',
'personal_team' => false,
]);
$user = User::factory()->create([
'email' => 'member@example.com',
]);
expect($user->teams()->where('personal_team', true)->exists())->toBeTrue();
$user->teams()->syncWithoutDetaching([
$rootTeam->id => ['role' => 'admin'],
]);
$response = $this->post('/login', [
'email' => 'member@example.com',
'password' => 'password',
]);
$response->assertRedirect();
expect(session('currentTeam')?->id)->toBe(0);
});
test('MapleDeploy root team admin sessions are repaired from personal team to root team', function () {
$rootTeam = Team::factory()->create([
'id' => 0,
'name' => 'Root Team',
'personal_team' => false,
]);
$user = User::factory()->create([
'email' => 'member@example.com',
]);
$personalTeam = $user->teams()->where('personal_team', true)->firstOrFail();
$user->teams()->syncWithoutDetaching([
$rootTeam->id => ['role' => 'admin'],
]);
$this->actingAs($user);
session(['currentTeam' => $personalTeam]);
app(DecideWhatToDoWithUser::class)->handle(
Request::create('/'),
fn () => response('ok'),
);
expect(session('currentTeam')?->id)->toBe(0);
});

View file

@ -145,7 +145,8 @@ function runMapledeployUserCommand(array $arguments, string $stdin = ''): array
->and($member['json']['user']['email'])->toBe('member@example.com');
$memberUser = User::whereEmail('member@example.com')->firstOrFail();
expect($memberUser->teams()->where('teams.id', 0)->first()?->pivot?->role)->toBe('admin');
expect($memberUser->teams()->where('teams.id', 0)->first()?->pivot?->role)->toBe('admin')
->and($memberUser->teams()->pluck('teams.id')->all())->toBe([0]);
$list = runMapledeployUserCommand(['mapledeploy:user:list']);
expect($list['exitCode'])->toBe(0)
@ -276,6 +277,29 @@ function runMapledeployUserCommand(array $arguments, string $stdin = ''): array
expect(DB::table('sessions')->where('user_id', 0)->count())->toBe(0);
});
test('MapleDeploy password command promotes matched native users to root team admin', function () {
runMapledeployUserCommand([
'mapledeploy:user:create',
'--admin',
'--email=owner@example.com',
'--name=Owner',
], "owner-password\n");
$nativeUser = User::factory()->create([
'email' => 'native-member@example.com',
'name' => 'Native Member',
]);
expect($nativeUser->teams()->where('teams.id', 0)->exists())->toBeFalse();
$reset = runMapledeployUserCommand([
'mapledeploy:user:set-password',
(string) $nativeUser->id,
], "native-member-password\n");
expect($reset['exitCode'])->toBe(0)
->and(Hash::check('native-member-password', $nativeUser->fresh()->password))->toBeTrue()
->and($nativeUser->fresh()->teams()->where('teams.id', 0)->first()?->pivot?->role)->toBe('admin');
});
test('MapleDeploy password command rejects ownership transfer to an existing email', function () {
runMapledeployUserCommand([
'mapledeploy:user:create',