From fbc92e6fcc0be300ce97b72e3d088f48054a7678 Mon Sep 17 00:00:00 2001 From: rosslh Date: Sun, 14 Jun 2026 11:47:50 -0400 Subject: [PATCH] feat(auth): add dashboard-managed Coolify users --- app/Actions/Fortify/CreateNewUser.php | 8 - app/Actions/Fortify/ResetUserPassword.php | 8 + .../Commands/Mapledeploy/UserCreate.php | 140 ++++++++ app/Console/Commands/Mapledeploy/UserList.php | 40 +++ .../Commands/Mapledeploy/UserRevoke.php | 55 ++++ .../Commands/Mapledeploy/UserSetPassword.php | 88 +++++ app/Http/Controllers/Controller.php | 5 + app/Http/Controllers/OauthController.php | 6 + app/Http/Kernel.php | 2 + .../RejectMapledeployRevokedUser.php | 37 +++ app/Models/User.php | 10 + app/Providers/FortifyServiceProvider.php | 27 +- config/database.php | 2 +- ...setup_token_to_instance_settings_table.php | 28 -- ...y_setup_columns_from_instance_settings.php | 47 +++ database/seeders/InstanceSettingsSeeder.php | 4 +- database/seeders/ProductionSeeder.php | 3 + lang/en.json | 4 +- lang/fr.json | 4 +- resources/views/auth/register.blade.php | 5 +- .../MapledeployInstanceSettingsSeederTest.php | 32 ++ .../MapledeployRevokedPasswordResetTest.php | 91 +++++ .../MapledeployUserManagementCommandsTest.php | 310 ++++++++++++++++++ 23 files changed, 890 insertions(+), 66 deletions(-) create mode 100644 app/Console/Commands/Mapledeploy/UserCreate.php create mode 100644 app/Console/Commands/Mapledeploy/UserList.php create mode 100644 app/Console/Commands/Mapledeploy/UserRevoke.php create mode 100644 app/Console/Commands/Mapledeploy/UserSetPassword.php create mode 100644 app/Http/Middleware/RejectMapledeployRevokedUser.php delete mode 100644 database/migrations/2026_02_21_000001_add_setup_token_to_instance_settings_table.php create mode 100644 database/migrations/2026_06_13_000000_drop_mapledeploy_setup_columns_from_instance_settings.php create mode 100644 tests/Feature/MapledeployInstanceSettingsSeederTest.php create mode 100644 tests/Feature/MapledeployRevokedPasswordResetTest.php create mode 100644 tests/Feature/MapledeployUserManagementCommandsTest.php diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index f4fbc6af1..cddf66389 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -36,14 +36,6 @@ public function create(array $input): User ])->validate(); if (User::count() == 0) { - // MapleDeploy: validate setup token for first user registration - if ($settings->setup_token) { - $providedToken = $input['setup_token'] ?? null; - if (! $providedToken || ! hash_equals($settings->setup_token, $providedToken)) { - abort(403); - } - } - // If this is the first user, make them the root user // Team is already created in the database/seeders/ProductionSeeder.php $user = (new User)->forceFill([ 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..284f5c071 --- /dev/null +++ b/app/Console/Commands/Mapledeploy/UserCreate.php @@ -0,0 +1,140 @@ +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(); + $user->teams()->syncWithoutDetaching([ + $rootTeam->id => ['role' => $input['team_role']], + ]); + + return $user; + }); + + return $this->succeedWithUser($user); + } + + 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..ba6d6614a --- /dev/null +++ b/app/Console/Commands/Mapledeploy/UserSetPassword.php @@ -0,0 +1,88 @@ +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'); + } + + $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']; + } + + $user->forceFill($changes)->save(); + if ($updatesOwner && ! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + } + // MapleDeploy branding: password resets from the dashboard should end + // any browser sessions authenticated with the previous password. + DB::table('sessions')->where('user_id', $user->id)->delete(); + + $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..abd386425 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()) ); 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/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 b3fe47e63..b42515c10 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -289,9 +289,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 159611caf..6817aaf44 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; @@ -51,17 +52,8 @@ public function boot(): void $isFirstUser = User::count() === 0; - // MapleDeploy: token-gated registration for first user - if ($isFirstUser && $settings->setup_token) { - $token = request()->query('setup_token'); - if (! $token || ! hash_equals($settings->setup_token, $token)) { - abort(403); - } - } - return view('auth.register', [ 'isFirstUser' => $isFirstUser, - 'setupToken' => request()->query('setup_token'), ]); }); @@ -69,16 +61,11 @@ public function boot(): void $settings = instanceSettings(); $enabled_oauth_providers = OauthSetting::where('enabled', true)->get(); $users = User::count(); - if ($users == 0) { - // MapleDeploy: don't redirect to register if setup token is required - if ($settings->setup_token) { - return view('auth.login', [ - 'setup_pending' => true, - 'is_registration_enabled' => false, - 'enabled_oauth_providers' => collect(), - ]); - } - + // 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'); } @@ -99,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 diff --git a/config/database.php b/config/database.php index 9238a7055..91fa6f2fc 100644 --- a/config/database.php +++ b/config/database.php @@ -97,7 +97,7 @@ 'testing' => [ 'driver' => 'sqlite', - 'database' => ':memory:', + 'database' => env('DB_DATABASE', ':memory:'), 'prefix' => '', 'foreign_key_constraints' => true, ], diff --git a/database/migrations/2026_02_21_000001_add_setup_token_to_instance_settings_table.php b/database/migrations/2026_02_21_000001_add_setup_token_to_instance_settings_table.php deleted file mode 100644 index b5fa1c930..000000000 --- a/database/migrations/2026_02_21_000001_add_setup_token_to_instance_settings_table.php +++ /dev/null @@ -1,28 +0,0 @@ -string('setup_token')->nullable(); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('instance_settings', function (Blueprint $table) { - $table->dropColumn('setup_token'); - }); - } -}; 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/register.blade.php b/resources/views/auth/register.blade.php index 2492c534e..d4773835d 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -53,9 +53,6 @@ function getOldOrLocal($key, $localValue)
@csrf - @if (isset($setupToken)) - - @endif 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'); + + $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 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']); +});