diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php index 5baa8b7ed..423029841 100644 --- a/app/Actions/Fortify/ResetUserPassword.php +++ b/app/Actions/Fortify/ResetUserPassword.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rules\Password; +use Illuminate\Validation\ValidationException; use Laravel\Fortify\Contracts\ResetsUserPasswords; class ResetUserPassword implements ResetsUserPasswords @@ -17,6 +18,13 @@ class ResetUserPassword implements ResetsUserPasswords */ public function reset(User $user, array $input): void { + if ($user->isMapledeployRevoked()) { + // MapleDeploy branding: dashboard-managed revocation is restored only by mapledeploy:user:set-password. + throw ValidationException::withMessages([ + 'email' => [trans('passwords.user')], + ]); + } + Validator::make($input, [ 'password' => ['required', Password::defaults(), 'confirmed'], ])->validate(); diff --git a/app/Console/Commands/Mapledeploy/UserCreate.php b/app/Console/Commands/Mapledeploy/UserCreate.php new file mode 100644 index 000000000..322ed2a4a --- /dev/null +++ b/app/Console/Commands/Mapledeploy/UserCreate.php @@ -0,0 +1,160 @@ +readPassword(); + $input = [ + 'email' => $this->option('email'), + 'name' => $this->option('name'), + 'password' => $password, + 'team_role' => $this->option('team-role'), + ]; + + $validator = Validator::make($input, [ + 'email' => ['required', 'string', 'email', 'max:255'], + 'name' => ['required', 'string', 'max:255'], + 'password' => ['required', 'string', 'min:8'], + 'team_role' => ['required', Rule::in([Role::ADMIN->value, Role::MEMBER->value])], + ]); + + if ($validator->fails()) { + return $this->failWith('INVALID_INPUT'); + } + + $input['email'] = Str::lower((string) $input['email']); + + if (User::whereEmail($input['email'])->exists()) { + return $this->failWith('EMAIL_EXISTS'); + } + + if ($this->option('admin')) { + return $this->createAdmin($input); + } + + return $this->createMember($input); + } + + private function createAdmin(array $input): int + { + if (User::count() !== 0) { + return $this->failWith('USERS_ALREADY_EXIST'); + } + + $user = DB::transaction(function () use ($input) { + $user = (new User)->forceFill([ + 'id' => 0, + 'name' => $input['name'], + 'email' => $input['email'], + 'password' => Hash::make($input['password']), + ]); + $user->save(); + $user->markEmailAsVerified(); + + $settings = instanceSettings(); + $settings->is_registration_enabled = false; + $attributes = $settings->getAttributes(); + if (array_key_exists('setup_token', $attributes)) { + $settings->setup_token = null; + } + if (array_key_exists('setup_callback_url', $attributes)) { + $settings->setup_callback_url = null; + } + $settings->save(); + + return $user; + }); + + return $this->succeedWithUser($user); + } + + private function createMember(array $input): int + { + $rootTeam = Team::find(0); + if (! $rootTeam) { + return $this->failWith('ROOT_TEAM_MISSING'); + } + + $user = DB::transaction(function () use ($input, $rootTeam) { + $user = User::create([ + 'name' => $input['name'], + 'email' => $input['email'], + 'password' => Hash::make($input['password']), + ]); + $user->markEmailAsVerified(); + $this->deletePersonalTeams($user); + $user->teams()->syncWithoutDetaching([ + $rootTeam->id => ['role' => $input['team_role']], + ]); + + return $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 + { + return rtrim((string) stream_get_contents(STDIN), "\n"); + } + + private function succeedWithUser(User $user): int + { + $this->line(json_encode([ + 'user' => [ + 'id' => $user->id, + 'email' => $user->email, + 'name' => $user->name, + ], + ], JSON_THROW_ON_ERROR)); + + return self::SUCCESS; + } + + private function failWith(string $code): int + { + $this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR)); + + return self::FAILURE; + } +} diff --git a/app/Console/Commands/Mapledeploy/UserList.php b/app/Console/Commands/Mapledeploy/UserList.php new file mode 100644 index 000000000..bc80cb688 --- /dev/null +++ b/app/Console/Commands/Mapledeploy/UserList.php @@ -0,0 +1,40 @@ +orderBy('id') + ->get() + ->map(fn (User $user) => [ + 'id' => $user->id, + 'email' => $user->email, + 'name' => $user->name, + 'created_at' => $user->created_at?->toISOString(), + 'teams' => $user->teams + ->map(fn ($team) => [ + 'id' => $team->id, + 'name' => $team->name, + 'role' => $team->pivot?->role, + ]) + ->values() + ->all(), + ]) + ->values() + ->all(); + + $this->line(json_encode(['users' => $users], JSON_THROW_ON_ERROR)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Mapledeploy/UserRevoke.php b/app/Console/Commands/Mapledeploy/UserRevoke.php new file mode 100644 index 000000000..3832dc702 --- /dev/null +++ b/app/Console/Commands/Mapledeploy/UserRevoke.php @@ -0,0 +1,55 @@ +argument('user_id'); + if ($userId === 0) { + return $this->failWith('CANNOT_REVOKE_ROOT_USER'); + } + + $user = User::find($userId); + if (! $user) { + return $this->failWith('USER_NOT_FOUND'); + } + + $user->forceFill([ + 'password' => Hash::make(Str::random(64)), + // MapleDeploy branding: OAuth login matches by email, so keep a + // persistent marker that the callback can reject after revocation. + 'remember_token' => 'mapledeploy-revoked:'.Str::random(40), + ])->save(); + $user->tokens()->delete(); + // MapleDeploy branding: revocation must end any active browser sessions. + DB::table('sessions')->where('user_id', $user->id)->delete(); + + $this->line(json_encode([ + 'revoked' => [ + 'id' => $user->id, + 'email' => $user->email, + ], + ], JSON_THROW_ON_ERROR)); + + return self::SUCCESS; + } + + private function failWith(string $code): int + { + $this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR)); + + return self::FAILURE; + } +} diff --git a/app/Console/Commands/Mapledeploy/UserSetPassword.php b/app/Console/Commands/Mapledeploy/UserSetPassword.php new file mode 100644 index 000000000..b4d344652 --- /dev/null +++ b/app/Console/Commands/Mapledeploy/UserSetPassword.php @@ -0,0 +1,107 @@ +option('email') !== null || $this->option('name') !== null; + $input = [ + 'password' => $password, + 'email' => $this->option('email'), + 'name' => $this->option('name'), + ]; + $rules = ['password' => ['required', 'string', 'min:8']]; + if ($updatesOwner) { + $rules['email'] = ['required', 'string', 'email', 'max:255']; + $rules['name'] = ['required', 'string', 'max:255']; + } + $validator = Validator::make($input, $rules); + + if ($validator->fails()) { + return $this->failWith('INVALID_INPUT'); + } + + $user = User::find($this->argument('user_id')); + 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), + // MapleDeploy branding: clear the revocation marker when the + // dashboard intentionally restores this Coolify login. + 'remember_token' => null, + ]; + if ($updatesOwner) { + $email = Str::lower((string) $input['email']); + if (User::whereEmail($email)->whereKeyNot($user->id)->exists()) { + return $this->failWith('EMAIL_EXISTS'); + } + // MapleDeploy branding: claiming root admin transfers the Coolify + // account identity so the previous email holder cannot recover it. + $changes['email'] = $email; + $changes['name'] = $input['name']; + } + + 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' => [ + 'id' => $user->id, + 'email' => $user->email, + 'name' => $user->name, + ], + ], JSON_THROW_ON_ERROR)); + + return self::SUCCESS; + } + + private function failWith(string $code): int + { + $this->line(json_encode(['error' => $code], JSON_THROW_ON_ERROR)); + + return self::FAILURE; + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 3090538c3..899a8f88f 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -79,6 +79,11 @@ public function forgot_password(Request $request) return response()->json(['message' => 'Transactional emails are not active'], 400); } $request->validate([Fortify::email() => 'required|email']); + $user = User::where('email', $request->input(Fortify::email()))->first(); + if ($user?->isMapledeployRevoked()) { + // MapleDeploy branding: only the dashboard set-password path can restore revoked users. + return app(SuccessfulPasswordResetLinkRequestResponse::class, ['status' => Password::RESET_LINK_SENT]); + } $status = Password::broker(config('fortify.passwords'))->sendResetLink( $request->only(Fortify::email()) ); @@ -128,16 +133,27 @@ public function link() ->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid)) ->where('link', request()->fullUrl()) ->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.'); } if (Hash::check($password, $user->password)) { - $team = $invitation->team; - if (! $user->teams()->where('team_id', $team->id)->exists()) { - $user->teams()->attach($team->id, ['role' => $invitation->role]); + if ($invitation) { + $team = $invitation->team; + 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); $user->forceFill([ diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 4038fe63e..bbe432876 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -25,6 +25,12 @@ public function callback(string $provider) } $email = strtolower($email); $user = User::whereEmail($email)->first(); + // MapleDeploy branding: dashboard revocation scrambles passwords, + // clears sessions, and marks the user so email-matched OAuth cannot + // reopen access. + if ($user?->isMapledeployRevoked()) { + abort(403, 'User access has been revoked'); + } if (! $user) { $settings = instanceSettings(); if (! $settings->is_registration_enabled) { diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 02a49aaa8..86bc74039 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -15,6 +15,7 @@ use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember; use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Http\Middleware\RedirectIfAuthenticated; +use App\Http\Middleware\RejectMapledeployRevokedUser; use App\Http\Middleware\TrimStrings; use App\Http\Middleware\TrustHosts; use App\Http\Middleware\TrustProxies; @@ -71,6 +72,7 @@ class Kernel extends HttpKernel ShareErrorsFromSession::class, VerifyCsrfToken::class, SubstituteBindings::class, + RejectMapledeployRevokedUser::class, CheckForcePasswordReset::class, DecideWhatToDoWithUser::class, 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/Http/Middleware/RejectMapledeployRevokedUser.php b/app/Http/Middleware/RejectMapledeployRevokedUser.php new file mode 100644 index 000000000..1cafcd743 --- /dev/null +++ b/app/Http/Middleware/RejectMapledeployRevokedUser.php @@ -0,0 +1,37 @@ +user(); + if (! $user?->isMapledeployRevoked()) { + return $next($request); + } + + // MapleDeploy branding: revocation is marked on the user row so old + // browser sessions are rejected even when SESSION_DRIVER is not database. + auth()->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + if ($request->routeIs('login') || $request->path() === 'login') { + return $next($request); + } + + return redirect()->route('login')->withErrors([ + 'email' => __('auth.failed'), + ]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 9cbe88835..f52d81441 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -93,7 +93,7 @@ protected static function boot() $team = [ 'name' => $user->name."'s Team", 'personal_team' => true, - 'show_boarding' => true, + 'show_boarding' => false, ]; if ($user->id === 0) { $team['id'] = 0; @@ -216,7 +216,7 @@ public function recreate_personal_team() $team = [ 'name' => $this->name."'s Team", 'personal_team' => true, - 'show_boarding' => true, + 'show_boarding' => false, ]; if ($this->id === 0) { $team['id'] = 0; @@ -229,6 +229,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( @@ -289,9 +303,19 @@ public function sendVerificationEmail() public function sendPasswordResetNotification($token): void { + if ($this->isMapledeployRevoked()) { + return; + } + $this?->notify(new TransactionalEmailsResetPassword($token)); } + public function isMapledeployRevoked(): bool + { + // MapleDeploy branding: dashboard-managed revocation stores a persistent marker. + return str_starts_with((string) $this->remember_token, 'mapledeploy-revoked:'); + } + public function isAdmin() { return $this->role() === 'admin' || $this->role() === 'owner'; diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 85f38b967..47ee25679 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -7,6 +7,7 @@ use App\Actions\Fortify\UpdateUserPassword; use App\Actions\Fortify\UpdateUserProfileInformation; use App\Models\OauthSetting; +use App\Models\TeamInvitation; use App\Models\User; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; @@ -44,13 +45,13 @@ public function boot(): void { Fortify::createUsersUsing(CreateNewUser::class); Fortify::registerView(function () { - $isFirstUser = User::count() === 0; - $settings = instanceSettings(); if (! $settings->is_registration_enabled) { return redirect()->route('login'); } + $isFirstUser = User::count() === 0; + return view('auth.register', [ 'isFirstUser' => $isFirstUser, ]); @@ -60,8 +61,11 @@ public function boot(): void $settings = instanceSettings(); $enabled_oauth_providers = OauthSetting::where('enabled', true)->get(); $users = User::count(); - if ($users == 0) { - // If there are no users, redirect to registration + // MapleDeploy branding: public registration is disabled by default + // because the dashboard creates the first admin over SSH. Do not + // redirect to /register in that fail-closed state, or fresh/failed + // provisioning loops between login and registration. + if ($users == 0 && $settings->is_registration_enabled) { return redirect()->route('register'); } @@ -82,7 +86,7 @@ public function boot(): void $user->save(); // Check if user has a pending invitation they haven't accepted yet - $invitation = \App\Models\TeamInvitation::whereEmail($email)->first(); + $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 @@ -92,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/config/database.php b/config/database.php index 9238a7055..dce5a4f59 100644 --- a/config/database.php +++ b/config/database.php @@ -21,6 +21,13 @@ return $fallbackHosts === [] ? ['coolify-db'] : $fallbackHosts; }; +$pgsqlOptions = []; +if (defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES')) { + $pgsqlOptions[Pgsql::ATTR_DISABLE_PREPARES] = env('DB_DISABLE_PREPARES', false); +} elseif (defined('\PDO::PGSQL_ATTR_DISABLE_PREPARES')) { + $pgsqlOptions[PDO::PGSQL_ATTR_DISABLE_PREPARES] = env('DB_DISABLE_PREPARES', false); +} + $pgsql = [ 'driver' => 'pgsql', 'url' => env('DATABASE_URL'), @@ -34,9 +41,7 @@ 'prefix_indexes' => true, 'search_path' => 'public', 'sslmode' => 'prefer', - 'options' => [ - (defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? Pgsql::ATTR_DISABLE_PREPARES : PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false), - ], + 'options' => $pgsqlOptions, ]; /* @@ -97,7 +102,7 @@ 'testing' => [ 'driver' => 'sqlite', - 'database' => ':memory:', + 'database' => env('DB_DATABASE', ':memory:'), 'prefix' => '', 'foreign_key_constraints' => true, ], diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php index 123fd226d..60553264c 100644 --- a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php +++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php @@ -11,12 +11,20 @@ */ public function up(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text'); } public function down(): void { + if (DB::connection()->getDriverName() !== 'pgsql') { + return; + } + DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json'); DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json'); } diff --git a/database/migrations/2026_06_13_000000_drop_mapledeploy_setup_columns_from_instance_settings.php b/database/migrations/2026_06_13_000000_drop_mapledeploy_setup_columns_from_instance_settings.php new file mode 100644 index 000000000..82cefbd35 --- /dev/null +++ b/database/migrations/2026_06_13_000000_drop_mapledeploy_setup_columns_from_instance_settings.php @@ -0,0 +1,47 @@ + Schema::hasColumn('instance_settings', $column), + )); + + if ($columns === []) { + return; + } + + Schema::table('instance_settings', function (Blueprint $table) use ($columns) { + // MapleDeploy branding: remove legacy one-time setup columns from customer instances. + $table->dropColumn($columns); + }); + } + + public function down(): void + { + $missingColumns = array_values(array_filter( + ['setup_token', 'setup_callback_url'], + fn (string $column) => ! Schema::hasColumn('instance_settings', $column), + )); + + if ($missingColumns === []) { + return; + } + + Schema::table('instance_settings', function (Blueprint $table) use ($missingColumns) { + if (in_array('setup_token', $missingColumns, true)) { + $table->text('setup_token')->nullable(); + } + + if (in_array('setup_callback_url', $missingColumns, true)) { + $table->text('setup_callback_url')->nullable(); + } + }); + } +}; diff --git a/database/seeders/InstanceSettingsSeeder.php b/database/seeders/InstanceSettingsSeeder.php index 930a7db8e..54184924e 100644 --- a/database/seeders/InstanceSettingsSeeder.php +++ b/database/seeders/InstanceSettingsSeeder.php @@ -15,7 +15,9 @@ public function run(): void { InstanceSettings::create([ 'id' => 0, - 'is_registration_enabled' => true, + // MapleDeploy branding: dashboard provisioning creates the first + // admin over SSH, so public registration must fail closed. + 'is_registration_enabled' => false, 'is_api_enabled' => isDev(), 'smtp_enabled' => true, 'smtp_host' => 'coolify-mail', diff --git a/database/seeders/ProductionSeeder.php b/database/seeders/ProductionSeeder.php index 4d492a297..ab3faf8d9 100644 --- a/database/seeders/ProductionSeeder.php +++ b/database/seeders/ProductionSeeder.php @@ -57,6 +57,9 @@ public function run(): void if (InstanceSettings::find(0) == null) { InstanceSettings::create([ 'id' => 0, + // MapleDeploy branding: dashboard provisioning creates the + // first admin over SSH, so public registration must fail closed. + 'is_registration_enabled' => false, ]); } diff --git a/lang/en.json b/lang/en.json index a81e1ee68..61cbcb279 100644 --- a/lang/en.json +++ b/lang/en.json @@ -18,7 +18,7 @@ "auth.register_now": "Register", "auth.logout": "Logout", "auth.register": "Register", - "auth.registration_disabled": "Registration is disabled. Please contact the administrator.", + "auth.registration_disabled": "Set up server access in the MapleDeploy dashboard.", "auth.reset_password": "Reset password", "auth.failed": "These credentials do not match our records.", "auth.failed.callback": "Failed to process callback from login provider.", @@ -41,4 +41,4 @@ "resource.delete_configurations": "Permanently delete all configuration files from the server.", "database.delete_backups_locally": "All backups will be permanently deleted from local storage.", "warning.sslipdomain": "Your configuration is saved, but sslip domain with https is NOT recommended, because Let's Encrypt servers with this public domain are rate limited (SSL certificate validation will fail).

Use your own domain instead." -} \ No newline at end of file +} diff --git a/lang/fr.json b/lang/fr.json index d98a1ebc8..4e82ff68e 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -17,7 +17,7 @@ "auth.register_now": "S'enregistrer", "auth.logout": "Déconnexion", "auth.register": "S'enregistrer", - "auth.registration_disabled": "L'enregistrement est désactivé. Merci de contacter l'administrateur.", + "auth.registration_disabled": "Configurez l’accès au serveur dans le tableau de bord MapleDeploy.", "auth.reset_password": "Réinitialiser le mot de passe", "auth.failed": "Aucune correspondance n'a été trouvée pour les informations d'identification renseignées.", "auth.failed.callback": "Erreur lors du processus de retour de la plateforme de connexion.", @@ -40,4 +40,4 @@ "resource.delete_configurations": "Supprimer définitivement tous les fichiers de configuration du serveur.", "database.delete_backups_locally": "Toutes les sauvegardes seront définitivement supprimées du stockage local.", "warning.sslipdomain": "Votre configuration est enregistrée, mais l'utilisation du domaine sslip avec https N'EST PAS recommandée, car les serveurs Let's Encrypt avec ce domaine public sont limités en taux (la validation du certificat SSL échouera).

Utilisez plutôt votre propre domaine." -} \ No newline at end of file +} diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index ede49117a..d15d89339 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -3,12 +3,33 @@
-

- Coolify -

+
+ MapleDeploy + +
+ @if (!empty($setup_pending)) + {{-- MapleDeploy: setup token required but not provided --}} +
+
+ + + +
+

Setup pending

+

+ Initial setup has not been completed. Please use the setup link from your + MapleDeploy dashboard. +

+
+
+
+ @else @if (session('status'))

{{ session('status') }}

@@ -95,6 +116,7 @@ class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 d @endforeach
@endif + @endif {{-- end setup_pending --}}
diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index f7ab57a14..d4773835d 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -15,9 +15,10 @@ function getOldOrLocal($key, $localValue)
-

- Coolify -

+
+ MapleDeploy + +

Create your account

@@ -96,4 +97,4 @@ class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 d
- \ No newline at end of file + diff --git a/tests/Feature/MapledeployInstanceSettingsSeederTest.php b/tests/Feature/MapledeployInstanceSettingsSeederTest.php new file mode 100644 index 000000000..95299385f --- /dev/null +++ b/tests/Feature/MapledeployInstanceSettingsSeederTest.php @@ -0,0 +1,32 @@ +seed(InstanceSettingsSeeder::class); + + expect((bool) InstanceSettings::findOrFail(0)->is_registration_enabled)->toBeFalse(); +}); + +test('login page does not redirect to registration when no users exist and registration is disabled', function () { + config()->set('app.maintenance.driver', 'file'); + $this->app->instance(Vite::class, new class + { + public function __invoke(): string + { + return ''; + } + }); + $this->seed(InstanceSettingsSeeder::class); + + $response = $this->get(route('login')); + + $response->assertOk(); + $response->assertViewIs('auth.login'); + $response->assertViewHas('is_registration_enabled', false); +}); 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/MapledeployRevokedPasswordResetTest.php b/tests/Feature/MapledeployRevokedPasswordResetTest.php new file mode 100644 index 000000000..1aa17247a --- /dev/null +++ b/tests/Feature/MapledeployRevokedPasswordResetTest.php @@ -0,0 +1,91 @@ + 'file', + 'cache.default' => 'array', + 'session.driver' => 'array', + ]); + InstanceSettings::unguarded(function () { + InstanceSettings::query()->create([ + 'id' => 0, + 'smtp_enabled' => true, + 'smtp_from_address' => 'test@example.com', + 'smtp_from_name' => 'MapleDeploy', + 'smtp_host' => 'localhost', + 'smtp_port' => 1025, + ]); + }); + Once::flush(); +}); + +test('forgot password does not create a reset token for MapleDeploy revoked users', function () { + $user = User::factory()->create([ + 'email' => 'revoked@example.com', + 'remember_token' => 'mapledeploy-revoked:abc123', + ]); + + $response = $this->post('/forgot-password', [ + 'email' => 'revoked@example.com', + ]); + + $response->assertSessionHas('status'); + expect(DB::table('password_reset_tokens')->where('email', $user->email)->exists())->toBeFalse(); + Notification::assertNothingSent(); +}); + +test('forgot password still sends reset links for active users', function () { + $user = User::factory()->create([ + 'email' => 'active@example.com', + 'remember_token' => null, + ]); + + $response = $this->post('/forgot-password', [ + 'email' => 'active@example.com', + ]); + + $response->assertSessionHas('status'); + expect(DB::table('password_reset_tokens')->where('email', $user->email)->exists())->toBeTrue(); + Notification::assertSentTo($user, ResetPassword::class); +}); + +test('reset password refuses MapleDeploy revoked users even with an existing token', function () { + $user = User::factory()->create([ + 'password' => Hash::make('old-password'), + 'remember_token' => 'mapledeploy-revoked:abc123', + ]); + + expect(fn () => app(ResetUserPassword::class)->reset($user, [ + 'password' => 'new-password', + 'password_confirmation' => 'new-password', + ]))->toThrow(ValidationException::class); + + expect(Hash::check('old-password', $user->fresh()->password))->toBeTrue() + ->and($user->fresh()->remember_token)->toBe('mapledeploy-revoked:abc123'); +}); + +test('revoked users are logged out even when sessions are not database backed', function () { + $user = User::factory()->create([ + 'remember_token' => 'mapledeploy-revoked:abc123', + 'email_verified_at' => now(), + ]); + + $response = $this->actingAs($user)->get('/'); + + $response->assertRedirect(route('login')); + $this->assertGuest(); +}); diff --git a/tests/Feature/MapledeployUserManagementCommandsTest.php b/tests/Feature/MapledeployUserManagementCommandsTest.php new file mode 100644 index 000000000..f1fcf9706 --- /dev/null +++ b/tests/Feature/MapledeployUserManagementCommandsTest.php @@ -0,0 +1,334 @@ +originalDatabaseConfig = [ + 'default' => config('database.default'), + 'testing_database' => config('database.connections.testing.database'), + ]; + $this->originalDatabaseEnvironment = [ + 'DB_CONNECTION' => [ + 'env' => $_ENV['DB_CONNECTION'] ?? null, + 'server' => $_SERVER['DB_CONNECTION'] ?? null, + 'process' => getenv('DB_CONNECTION') === false ? null : getenv('DB_CONNECTION'), + ], + 'DB_DATABASE' => [ + 'env' => $_ENV['DB_DATABASE'] ?? null, + 'server' => $_SERVER['DB_DATABASE'] ?? null, + 'process' => getenv('DB_DATABASE') === false ? null : getenv('DB_DATABASE'), + ], + ]; + $this->databasePath = storage_path('framework/testing-mapledeploy-user-mgmt-'.bin2hex(random_bytes(6)).'.sqlite'); + touch($this->databasePath); + + config([ + 'database.default' => 'testing', + 'database.connections.testing.database' => $this->databasePath, + ]); + $_ENV['DB_CONNECTION'] = 'testing'; + $_SERVER['DB_CONNECTION'] = 'testing'; + $_ENV['DB_DATABASE'] = $this->databasePath; + $_SERVER['DB_DATABASE'] = $this->databasePath; + putenv('DB_CONNECTION=testing'); + putenv("DB_DATABASE={$this->databasePath}"); + $GLOBALS['mapledeployUserMgmtDatabasePath'] = $this->databasePath; + + DB::purge('testing'); + DB::reconnect('testing'); + Artisan::call('migrate:fresh', ['--database' => 'testing']); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); +}); + +afterEach(function () { + DB::disconnect('testing'); + DB::purge('testing'); + config([ + 'database.default' => $this->originalDatabaseConfig['default'] ?? null, + 'database.connections.testing.database' => $this->originalDatabaseConfig['testing_database'] ?? null, + ]); + if (isset($this->databasePath) && file_exists($this->databasePath)) { + unlink($this->databasePath); + } + foreach (($this->originalDatabaseEnvironment ?? []) as $key => $values) { + if ($values['env'] === null) { + unset($_ENV[$key]); + } else { + $_ENV[$key] = $values['env']; + } + if ($values['server'] === null) { + unset($_SERVER[$key]); + } else { + $_SERVER[$key] = $values['server']; + } + if ($values['process'] === null) { + putenv($key); + } else { + putenv("{$key}={$values['process']}"); + } + } + unset($GLOBALS['mapledeployUserMgmtDatabasePath']); +}); + +function runMapledeployUserCommand(array $arguments, string $stdin = ''): array +{ + $process = new Process( + [PHP_BINARY, 'artisan', ...$arguments], + base_path(), + [ + 'APP_ENV' => 'testing', + 'APP_KEY' => config('app.key'), + 'DB_CONNECTION' => 'testing', + 'DB_DATABASE' => $GLOBALS['mapledeployUserMgmtDatabasePath'], + 'CACHE_DRIVER' => 'array', + 'SESSION_DRIVER' => 'database', + 'QUEUE_CONNECTION' => 'sync', + 'MAIL_MAILER' => 'array', + 'SELF_HOSTED' => 'true', + ], + ); + $process->setInput($stdin); + $process->setTimeout(30); + $process->run(); + + $jsonLine = collect(explode("\n", $process->getOutput())) + ->map(fn (string $line) => trim($line)) + ->first(fn (string $line) => str_starts_with($line, '{') && str_ends_with($line, '}')); + + return [ + 'exitCode' => $process->getExitCode(), + 'json' => $jsonLine ? json_decode($jsonLine, true, flags: JSON_THROW_ON_ERROR) : null, + 'stdout' => $process->getOutput(), + 'stderr' => $process->getErrorOutput(), + ]; +} + +test('MapleDeploy user management commands create, list, reset, and revoke users', function () { + $admin = runMapledeployUserCommand([ + 'mapledeploy:user:create', + '--admin', + '--email=Owner@Example.com', + '--name=Owner', + ], "owner-password\n"); + + expect($admin['exitCode'])->toBe(0) + ->and($admin['json']['user'])->toMatchArray([ + 'id' => 0, + 'email' => 'owner@example.com', + 'name' => 'Owner', + ]) + ->and((bool) InstanceSettings::findOrFail(0)->is_registration_enabled)->toBeFalse() + ->and(Hash::check('owner-password', User::findOrFail(0)->password))->toBeTrue(); + + $duplicateOwner = runMapledeployUserCommand([ + 'mapledeploy:user:create', + '--email=OWNER@Example.com', + '--name=Duplicate Owner', + ], "duplicate-password\n"); + + expect($duplicateOwner['exitCode'])->toBe(1) + ->and($duplicateOwner['json'])->toBe(['error' => 'EMAIL_EXISTS']); + + $member = runMapledeployUserCommand([ + 'mapledeploy:user:create', + '--email=Member@Example.com', + '--name=Member', + '--team-role=admin', + ], "member-password\n"); + + expect($member['exitCode'])->toBe(0) + ->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') + ->and($memberUser->teams()->pluck('teams.id')->all())->toBe([0]); + + $list = runMapledeployUserCommand(['mapledeploy:user:list']); + expect($list['exitCode'])->toBe(0) + ->and(collect($list['json']['users'])->pluck('email')->all()) + ->toBe(['owner@example.com', 'member@example.com']); + + $resetOtherUser = User::factory()->create(); + DB::table('sessions')->insert([ + [ + 'id' => 'member-reset-session', + 'user_id' => $memberUser->id, + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Test Browser', + 'payload' => base64_encode('member-reset-payload'), + 'last_activity' => now()->timestamp, + ], + [ + 'id' => 'other-reset-session', + 'user_id' => $resetOtherUser->id, + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Test Browser', + 'payload' => base64_encode('other-reset-payload'), + 'last_activity' => now()->timestamp, + ], + ]); + + $reset = runMapledeployUserCommand([ + 'mapledeploy:user:set-password', + (string) $memberUser->id, + ], "new-member-password\n"); + expect($reset['exitCode'])->toBe(0) + ->and(Hash::check('new-member-password', $memberUser->fresh()->password))->toBeTrue(); + expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(0) + ->and(DB::table('sessions')->where('user_id', $resetOtherUser->id)->count())->toBe(1); + + DB::table('personal_access_tokens')->insert([ + 'tokenable_type' => User::class, + 'tokenable_id' => $memberUser->id, + 'name' => 'e2e-token', + 'token' => hash('sha256', 'e2e-token'), + 'team_id' => '0', + 'abilities' => json_encode(['*'], JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + expect($memberUser->tokens()->count())->toBe(1); + $otherUser = User::factory()->create(); + DB::table('sessions')->insert([ + [ + 'id' => 'member-session', + 'user_id' => $memberUser->id, + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Test Browser', + 'payload' => base64_encode('member-payload'), + 'last_activity' => now()->timestamp, + ], + [ + 'id' => 'other-session', + 'user_id' => $otherUser->id, + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Test Browser', + 'payload' => base64_encode('other-payload'), + 'last_activity' => now()->timestamp, + ], + ]); + expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(1); + + $revokeRoot = runMapledeployUserCommand(['mapledeploy:user:revoke', '0']); + expect($revokeRoot['exitCode'])->toBe(1) + ->and($revokeRoot['json'])->toBe(['error' => 'CANNOT_REVOKE_ROOT_USER']); + + $revoke = runMapledeployUserCommand([ + 'mapledeploy:user:revoke', + (string) $memberUser->id, + ]); + expect($revoke['exitCode'])->toBe(0) + ->and($revoke['json']['revoked']['email'])->toBe('member@example.com') + ->and($memberUser->fresh()->tokens()->count())->toBe(0); + expect(str_starts_with((string) $memberUser->fresh()->remember_token, 'mapledeploy-revoked:'))->toBeTrue(); + expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(0) + ->and(DB::table('sessions')->where('user_id', $otherUser->id)->count())->toBe(1); + + $restore = runMapledeployUserCommand([ + 'mapledeploy:user:set-password', + (string) $memberUser->id, + ], "restored-member-password\n"); + expect($restore['exitCode'])->toBe(0) + ->and(Hash::check('restored-member-password', $memberUser->fresh()->password))->toBeTrue() + ->and($memberUser->fresh()->remember_token)->toBeNull(); +}); + +test('MapleDeploy password command can transfer root ownership identity', function () { + runMapledeployUserCommand([ + 'mapledeploy:user:create', + '--admin', + '--email=old-owner@example.com', + '--name=Old Owner', + ], "old-owner-password\n"); + DB::table('sessions')->insert([ + 'id' => 'root-session', + 'user_id' => 0, + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Test Browser', + 'payload' => base64_encode('root-payload'), + 'last_activity' => now()->timestamp, + ]); + + $claim = runMapledeployUserCommand([ + 'mapledeploy:user:set-password', + '0', + '--email=New.Owner@Example.com', + '--name=New Owner', + ], "new-owner-password\n"); + + $root = User::findOrFail(0); + expect($claim['exitCode'])->toBe(0) + ->and($claim['json']['user'])->toMatchArray([ + 'id' => 0, + 'email' => 'new.owner@example.com', + 'name' => 'New Owner', + ]) + ->and($root->email)->toBe('new.owner@example.com') + ->and($root->name)->toBe('New Owner') + ->and(Hash::check('new-owner-password', $root->password))->toBeTrue() + ->and($root->remember_token)->toBeNull() + ->and($root->email_verified_at)->not->toBeNull(); + expect(User::whereEmail('old-owner@example.com')->exists())->toBeFalse(); + 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', + '--admin', + '--email=old-owner@example.com', + '--name=Old Owner', + ], "old-owner-password\n"); + $existing = User::factory()->create(['email' => 'new.owner@example.com']); + + $claim = runMapledeployUserCommand([ + 'mapledeploy:user:set-password', + '0', + '--email=new.owner@example.com', + '--name=New Owner', + ], "new-owner-password\n"); + + expect($claim['exitCode'])->toBe(1) + ->and($claim['json'])->toBe(['error' => 'EMAIL_EXISTS']) + ->and(User::findOrFail(0)->email)->toBe('old-owner@example.com') + ->and($existing->fresh()->email)->toBe('new.owner@example.com'); +}); + +test('MapleDeploy user creation command reports validation errors as JSON', function () { + $invalid = runMapledeployUserCommand([ + 'mapledeploy:user:create', + '--email=not-an-email', + '--name=Invalid', + ], "short\n"); + + expect($invalid['exitCode'])->toBe(1) + ->and($invalid['json'])->toBe(['error' => 'INVALID_INPUT']); +});