fix(coolify-access): prefer managed root team
This commit is contained in:
parent
fbc92e6fcc
commit
2d65973eeb
8 changed files with 179 additions and 15 deletions
|
|
@ -103,6 +103,7 @@ private function createMember(array $input): int
|
||||||
'password' => Hash::make($input['password']),
|
'password' => Hash::make($input['password']),
|
||||||
]);
|
]);
|
||||||
$user->markEmailAsVerified();
|
$user->markEmailAsVerified();
|
||||||
|
$this->deletePersonalTeams($user);
|
||||||
$user->teams()->syncWithoutDetaching([
|
$user->teams()->syncWithoutDetaching([
|
||||||
$rootTeam->id => ['role' => $input['team_role']],
|
$rootTeam->id => ['role' => $input['team_role']],
|
||||||
]);
|
]);
|
||||||
|
|
@ -113,6 +114,25 @@ private function createMember(array $input): int
|
||||||
return $this->succeedWithUser($user);
|
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
|
private function readPassword(): string
|
||||||
{
|
{
|
||||||
return rtrim((string) stream_get_contents(STDIN), "\n");
|
return rtrim((string) stream_get_contents(STDIN), "\n");
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
namespace App\Console\Commands\Mapledeploy;
|
namespace App\Console\Commands\Mapledeploy;
|
||||||
|
|
||||||
|
use App\Enums\Role;
|
||||||
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
@ -42,6 +44,13 @@ public function handle(): int
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
return $this->failWith('USER_NOT_FOUND');
|
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 = [
|
$changes = [
|
||||||
'password' => Hash::make($password),
|
'password' => Hash::make($password),
|
||||||
|
|
@ -60,13 +69,23 @@ public function handle(): int
|
||||||
$changes['name'] = $input['name'];
|
$changes['name'] = $input['name'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->forceFill($changes)->save();
|
DB::transaction(function () use ($user, $changes, $updatesOwner, $rootTeam) {
|
||||||
if ($updatesOwner && ! $user->hasVerifiedEmail()) {
|
$user->forceFill($changes)->save();
|
||||||
$user->markEmailAsVerified();
|
if ($updatesOwner && ! $user->hasVerifiedEmail()) {
|
||||||
}
|
$user->markEmailAsVerified();
|
||||||
// MapleDeploy branding: password resets from the dashboard should end
|
}
|
||||||
// any browser sessions authenticated with the previous password.
|
if ($rootTeam) {
|
||||||
DB::table('sessions')->where('user_id', $user->id)->delete();
|
// 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([
|
$this->line(json_encode([
|
||||||
'user' => [
|
'user' => [
|
||||||
|
|
|
||||||
|
|
@ -133,16 +133,27 @@ public function link()
|
||||||
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
|
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
|
||||||
->where('link', request()->fullUrl())
|
->where('link', request()->fullUrl())
|
||||||
->first();
|
->first();
|
||||||
if (! $invitation || ! $invitation->isValid()) {
|
|
||||||
|
if ($invitationUuid && ! $invitation) {
|
||||||
|
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($invitation && ! $invitation->isValid()) {
|
||||||
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Hash::check($password, $user->password)) {
|
if (Hash::check($password, $user->password)) {
|
||||||
$team = $invitation->team;
|
if ($invitation) {
|
||||||
if (! $user->teams()->where('team_id', $team->id)->exists()) {
|
$team = $invitation->team;
|
||||||
$user->teams()->attach($team->id, ['role' => $invitation->role]);
|
if (! $user->teams()->where('team_id', $team->id)->exists()) {
|
||||||
|
$user->teams()->attach($team->id, ['role' => $invitation->role]);
|
||||||
|
}
|
||||||
|
$invitation->delete();
|
||||||
|
} else {
|
||||||
|
// MapleDeploy branding: root-team admins should land in
|
||||||
|
// the managed instance team, not their empty personal team.
|
||||||
|
$team = $user->mapledeployPreferredTeam();
|
||||||
}
|
}
|
||||||
$invitation->delete();
|
|
||||||
|
|
||||||
Auth::login($user);
|
Auth::login($user);
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,16 @@ public function handle(Request $request, Closure $next): Response
|
||||||
$currentTeam = auth()->user()?->recreate_personal_team();
|
$currentTeam = auth()->user()?->recreate_personal_team();
|
||||||
refreshSession($currentTeam);
|
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()) {
|
if (auth()?->user()?->currentTeam()) {
|
||||||
refreshSession(auth()->user()->currentTeam());
|
refreshSession(auth()->user()->currentTeam());
|
||||||
} elseif (auth()?->user()?->teams?->count() > 0) {
|
} elseif (auth()?->user()?->teams?->count() > 0) {
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,20 @@ public function recreate_personal_team()
|
||||||
return $new_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)
|
public function createToken(string $name, array $abilities = ['*'], ?DateTimeInterface $expiresAt = null)
|
||||||
{
|
{
|
||||||
$plainTextToken = sprintf(
|
$plainTextToken = sprintf(
|
||||||
|
|
|
||||||
|
|
@ -96,8 +96,9 @@ public function boot(): void
|
||||||
$user->currentTeam = $invitation->team;
|
$user->currentTeam = $invitation->team;
|
||||||
$invitation->delete();
|
$invitation->delete();
|
||||||
} else {
|
} else {
|
||||||
// Normal login - use personal team
|
// MapleDeploy branding: root-team admins should land in
|
||||||
$user->currentTeam = $user->teams->firstWhere('personal_team', true);
|
// the managed instance team, not their empty personal team.
|
||||||
|
$user->currentTeam = $user->mapledeployPreferredTeam();
|
||||||
if (! $user->currentTeam) {
|
if (! $user->currentTeam) {
|
||||||
$user->currentTeam = $user->recreate_personal_team();
|
$user->currentTeam = $user->recreate_personal_team();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
tests/Feature/MapledeployPreferredTeamLoginTest.php
Normal file
65
tests/Feature/MapledeployPreferredTeamLoginTest.php
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -145,7 +145,8 @@ function runMapledeployUserCommand(array $arguments, string $stdin = ''): array
|
||||||
->and($member['json']['user']['email'])->toBe('member@example.com');
|
->and($member['json']['user']['email'])->toBe('member@example.com');
|
||||||
|
|
||||||
$memberUser = User::whereEmail('member@example.com')->firstOrFail();
|
$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']);
|
$list = runMapledeployUserCommand(['mapledeploy:user:list']);
|
||||||
expect($list['exitCode'])->toBe(0)
|
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);
|
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 () {
|
test('MapleDeploy password command rejects ownership transfer to an existing email', function () {
|
||||||
runMapledeployUserCommand([
|
runMapledeployUserCommand([
|
||||||
'mapledeploy:user:create',
|
'mapledeploy:user:create',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue