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)