diff --git a/app/Console/Commands/Mapledeploy/UserCreate.php b/app/Console/Commands/Mapledeploy/UserCreate.php index 284f5c071..322ed2a4a 100644 --- a/app/Console/Commands/Mapledeploy/UserCreate.php +++ b/app/Console/Commands/Mapledeploy/UserCreate.php @@ -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"); diff --git a/app/Console/Commands/Mapledeploy/UserSetPassword.php b/app/Console/Commands/Mapledeploy/UserSetPassword.php index ba6d6614a..b4d344652 100644 --- a/app/Console/Commands/Mapledeploy/UserSetPassword.php +++ b/app/Console/Commands/Mapledeploy/UserSetPassword.php @@ -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' => [ diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 944d00420..e435ead6b 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -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]); diff --git a/app/Http/Middleware/DecideWhatToDoWithUser.php b/app/Http/Middleware/DecideWhatToDoWithUser.php index dbf261f4d..748241c81 100644 --- a/app/Http/Middleware/DecideWhatToDoWithUser.php +++ b/app/Http/Middleware/DecideWhatToDoWithUser.php @@ -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) { diff --git a/app/Models/User.php b/app/Models/User.php index 49a9b89d5..582efa26c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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( diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 6817aaf44..47ee25679 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -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(); } diff --git a/tests/Feature/MapledeployPreferredTeamLoginTest.php b/tests/Feature/MapledeployPreferredTeamLoginTest.php new file mode 100644 index 000000000..9bab1baf8 --- /dev/null +++ b/tests/Feature/MapledeployPreferredTeamLoginTest.php @@ -0,0 +1,65 @@ + '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); +}); diff --git a/tests/Feature/MapledeployUserManagementCommandsTest.php b/tests/Feature/MapledeployUserManagementCommandsTest.php index 36575ae99..f1fcf9706 100644 --- a/tests/Feature/MapledeployUserManagementCommandsTest.php +++ b/tests/Feature/MapledeployUserManagementCommandsTest.php @@ -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',