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/UserDelete.php b/app/Console/Commands/Mapledeploy/UserDelete.php new file mode 100644 index 000000000..7c12c9945 --- /dev/null +++ b/app/Console/Commands/Mapledeploy/UserDelete.php @@ -0,0 +1,61 @@ +argument('user_id'); + $userId = filter_var($rawUserId, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); + if ($userId === false) { + return $this->failWith('INVALID_USER_ID'); + } + + if ($userId === 0) { + return $this->failWith('CANNOT_DELETE_ROOT_USER'); + } + + $user = User::find($userId); + if (! $user) { + $this->line(json_encode([ + 'deleted' => null, + 'alreadyDeleted' => true, + 'id' => $userId, + ], JSON_THROW_ON_ERROR)); + + return self::SUCCESS; + } + + $deleted = [ + 'id' => $user->id, + 'email' => $user->email, + ]; + + DB::transaction(function () use ($user) { + $user->tokens()->delete(); + // MapleDeploy branding: deletion must end any active browser sessions. + DB::table('sessions')->where('user_id', $user->id)->delete(); + $user->delete(); + }); + + $this->line(json_encode(['deleted' => $deleted], 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 9b6b315ce..48f61fce2 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -82,6 +82,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()) ); 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 aca429391..52c88a007 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -17,6 +17,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; @@ -75,6 +76,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 5b3847396..397a8e803 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -96,7 +96,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; @@ -219,7 +219,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; @@ -232,6 +232,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( @@ -292,9 +306,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 ce16e617d..ffee6ee1b 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -43,13 +43,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, ]); @@ -59,8 +59,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'); } @@ -91,8 +94,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_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 12c21b666..22fec79e8 100644 --- a/lang/en.json +++ b/lang/en.json @@ -19,7 +19,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.", @@ -42,4 +42,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 faf3db8c5..0ac8f033a 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -93,7 +93,7 @@ class="auth-tooltip max-w-xs whitespace-normal"> @if ($is_registration_enabled) - New to Coolify? + New to MapleDeploy? {{-- MapleDeploy branding --}} {{ __('auth.register_now') }} @else {{ __('auth.registration_disabled') }} 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..ae45a2081 --- /dev/null +++ b/tests/Feature/MapledeployUserManagementCommandsTest.php @@ -0,0 +1,403 @@ +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 user delete command removes non-root users', function () { + runMapledeployUserCommand([ + 'mapledeploy:user:create', + '--admin', + '--email=owner@example.com', + '--name=Owner', + ], "owner-password\n"); + + $member = runMapledeployUserCommand([ + 'mapledeploy:user:create', + '--email=delete-me@example.com', + '--name=Delete Me', + '--team-role=admin', + ], "member-password\n"); + $memberUser = User::findOrFail($member['json']['user']['id']); + + DB::table('personal_access_tokens')->insert([ + 'tokenable_type' => User::class, + 'tokenable_id' => $memberUser->id, + 'name' => 'delete-token', + 'token' => hash('sha256', 'delete-token'), + 'team_id' => '0', + 'abilities' => json_encode(['*'], JSON_THROW_ON_ERROR), + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('sessions')->insert([ + 'id' => 'delete-session', + 'user_id' => $memberUser->id, + 'ip_address' => '127.0.0.1', + 'user_agent' => 'Test Browser', + 'payload' => base64_encode('delete-payload'), + 'last_activity' => now()->timestamp, + ]); + + $deleteRoot = runMapledeployUserCommand(['mapledeploy:user:delete', '0']); + expect($deleteRoot['exitCode'])->toBe(1) + ->and($deleteRoot['json'])->toBe(['error' => 'CANNOT_DELETE_ROOT_USER']); + + $invalid = runMapledeployUserCommand(['mapledeploy:user:delete', 'not-a-user-id']); + expect($invalid['exitCode'])->toBe(1) + ->and($invalid['json'])->toBe(['error' => 'INVALID_USER_ID']); + + $delete = runMapledeployUserCommand([ + 'mapledeploy:user:delete', + (string) $memberUser->id, + ]); + + expect($delete['exitCode'])->toBe(0) + ->and($delete['json']['deleted'])->toBe([ + 'id' => $memberUser->id, + 'email' => 'delete-me@example.com', + ]) + ->and(User::find($memberUser->id))->toBeNull() + ->and(DB::table('personal_access_tokens')->where('tokenable_id', $memberUser->id)->count())->toBe(0) + ->and(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(0); + + $missing = runMapledeployUserCommand([ + 'mapledeploy:user:delete', + (string) $memberUser->id, + ]); + expect($missing['exitCode'])->toBe(0) + ->and($missing['json'])->toBe([ + 'deleted' => null, + 'alreadyDeleted' => true, + 'id' => $memberUser->id, + ]); +}); + +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']); +});