feat(auth): add dashboard-managed Coolify access

This commit is contained in:
rosslh 2026-06-19 20:23:56 -04:00
parent c2e2362abc
commit c3a4437a8f
25 changed files with 1110 additions and 30 deletions

View file

@ -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();

View file

@ -0,0 +1,160 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Enums\Role;
use App\Models\Team;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class UserCreate extends Command
{
protected $signature = 'mapledeploy:user:create
{--email= : User email address}
{--name= : User display name}
{--admin : Create the first root admin user}
{--team-role=member : Root team role for non-admin users}';
protected $description = 'Create a Coolify user for MapleDeploy dashboard access management';
public function handle(): int
{
$password = $this->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;
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Models\User;
use Illuminate\Console\Command;
class UserList extends Command
{
protected $signature = 'mapledeploy:user:list';
protected $description = 'List Coolify users for MapleDeploy dashboard access management';
public function handle(): int
{
$users = User::with('teams')
->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;
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class UserRevoke extends Command
{
protected $signature = 'mapledeploy:user:revoke {user_id : Coolify user id}';
protected $description = 'Revoke a Coolify user login for MapleDeploy dashboard access management';
public function handle(): int
{
$userId = (int) $this->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;
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Console\Commands\Mapledeploy;
use App\Enums\Role;
use App\Models\Team;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UserSetPassword extends Command
{
protected $signature = 'mapledeploy:user:set-password
{user_id : Coolify user id}
{--email= : New user email address}
{--name= : New user display name}';
protected $description = 'Set a Coolify user password for MapleDeploy dashboard access management';
public function handle(): int
{
$password = rtrim((string) stream_get_contents(STDIN), "\n");
$updatesOwner = $this->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;
}
}

View file

@ -79,6 +79,11 @@ public function forgot_password(Request $request)
return response()->json(['message' => 'Transactional emails are not active'], 400);
}
$request->validate([Fortify::email() => 'required|email']);
$user = User::where('email', $request->input(Fortify::email()))->first();
if ($user?->isMapledeployRevoked()) {
// MapleDeploy branding: only the dashboard set-password path can restore revoked users.
return app(SuccessfulPasswordResetLinkRequestResponse::class, ['status' => Password::RESET_LINK_SENT]);
}
$status = Password::broker(config('fortify.passwords'))->sendResetLink(
$request->only(Fortify::email())
);
@ -128,16 +133,27 @@ public function link()
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
->where('link', request()->fullUrl())
->first();
if (! $invitation || ! $invitation->isValid()) {
if ($invitationUuid && ! $invitation) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
if ($invitation && ! $invitation->isValid()) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
if (Hash::check($password, $user->password)) {
$team = $invitation->team;
if (! $user->teams()->where('team_id', $team->id)->exists()) {
$user->teams()->attach($team->id, ['role' => $invitation->role]);
if ($invitation) {
$team = $invitation->team;
if (! $user->teams()->where('team_id', $team->id)->exists()) {
$user->teams()->attach($team->id, ['role' => $invitation->role]);
}
$invitation->delete();
} else {
// MapleDeploy branding: root-team admins should land in
// the managed instance team, not their empty personal team.
$team = $user->mapledeployPreferredTeam();
}
$invitation->delete();
Auth::login($user);
$user->forceFill([

View file

@ -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) {

View file

@ -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,

View file

@ -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) {

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RejectMapledeployRevokedUser
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = auth()->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'),
]);
}
}

View file

@ -93,7 +93,7 @@ protected static function boot()
$team = [
'name' => $user->name."'s Team",
'personal_team' => true,
'show_boarding' => true,
'show_boarding' => false,
];
if ($user->id === 0) {
$team['id'] = 0;
@ -216,7 +216,7 @@ public function recreate_personal_team()
$team = [
'name' => $this->name."'s Team",
'personal_team' => true,
'show_boarding' => true,
'show_boarding' => false,
];
if ($this->id === 0) {
$team['id'] = 0;
@ -229,6 +229,20 @@ public function recreate_personal_team()
return $new_team;
}
public function mapledeployPreferredTeam(): ?Team
{
// MapleDeploy branding: dashboard-managed users are attached to the
// root team so they can administer the customer's managed instance.
$rootTeam = $this->teams->firstWhere('id', 0);
$rootRole = data_get($rootTeam, 'pivot.role');
if ($rootTeam && ($rootRole === 'admin' || $rootRole === 'owner')) {
return $rootTeam;
}
return $this->teams->firstWhere('personal_team', true)
?? $this->teams->first();
}
public function createToken(string $name, array $abilities = ['*'], ?DateTimeInterface $expiresAt = null)
{
$plainTextToken = sprintf(
@ -289,9 +303,19 @@ public function sendVerificationEmail()
public function sendPasswordResetNotification($token): void
{
if ($this->isMapledeployRevoked()) {
return;
}
$this?->notify(new TransactionalEmailsResetPassword($token));
}
public function isMapledeployRevoked(): bool
{
// MapleDeploy branding: dashboard-managed revocation stores a persistent marker.
return str_starts_with((string) $this->remember_token, 'mapledeploy-revoked:');
}
public function isAdmin()
{
return $this->role() === 'admin' || $this->role() === 'owner';

View file

@ -7,6 +7,7 @@
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Models\OauthSetting;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
@ -44,13 +45,13 @@ public function boot(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::registerView(function () {
$isFirstUser = User::count() === 0;
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
return redirect()->route('login');
}
$isFirstUser = User::count() === 0;
return view('auth.register', [
'isFirstUser' => $isFirstUser,
]);
@ -60,8 +61,11 @@ public function boot(): void
$settings = instanceSettings();
$enabled_oauth_providers = OauthSetting::where('enabled', true)->get();
$users = User::count();
if ($users == 0) {
// If there are no users, redirect to registration
// MapleDeploy branding: public registration is disabled by default
// because the dashboard creates the first admin over SSH. Do not
// redirect to /register in that fail-closed state, or fresh/failed
// provisioning loops between login and registration.
if ($users == 0 && $settings->is_registration_enabled) {
return redirect()->route('register');
}
@ -82,7 +86,7 @@ public function boot(): void
$user->save();
// Check if user has a pending invitation they haven't accepted yet
$invitation = \App\Models\TeamInvitation::whereEmail($email)->first();
$invitation = TeamInvitation::whereEmail($email)->first();
if ($invitation && $invitation->isValid()) {
// User is logging in for the first time after being invited
// Attach them to the invited team if not already attached
@ -92,8 +96,9 @@ public function boot(): void
$user->currentTeam = $invitation->team;
$invitation->delete();
} else {
// Normal login - use personal team
$user->currentTeam = $user->teams->firstWhere('personal_team', true);
// MapleDeploy branding: root-team admins should land in
// the managed instance team, not their empty personal team.
$user->currentTeam = $user->mapledeployPreferredTeam();
if (! $user->currentTeam) {
$user->currentTeam = $user->recreate_personal_team();
}

View file

@ -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,
],

View file

@ -11,12 +11,20 @@
*/
public function up(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text');
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
}
public function down(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json');
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json');
}

View file

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$columns = array_values(array_filter(
['setup_token', 'setup_callback_url'],
fn (string $column) => 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();
}
});
}
};

View file

@ -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',

View file

@ -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,
]);
}

View file

@ -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 <span class='dark:text-red-500 text-red-500 font-bold'>NOT</span> recommended, because Let's Encrypt servers with this public domain are rate limited (SSL certificate validation will fail). <br><br>Use your own domain instead."
}
}

View file

@ -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 laccè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 <span class='dark:text-red-500 text-red-500 font-bold'>N'EST PAS</span> recommandée, car les serveurs Let's Encrypt avec ce domaine public sont limités en taux (la validation du certificat SSL échouera). <br><br>Utilisez plutôt votre propre domaine."
}
}

View file

@ -3,12 +3,33 @@
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
</div>
<div class="space-y-6">
@if (!empty($setup_pending))
{{-- MapleDeploy: setup token required but not provided --}}
<div class="mb-6 p-4 bg-warning/10 border border-warning rounded-lg">
<div class="flex gap-3">
<svg class="size-5 text-warning flex-shrink-0 mt-0.5" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z"
clip-rule="evenodd" />
</svg>
<div>
<p class="font-bold text-warning">Setup pending</p>
<p class="text-sm dark:text-white text-black">
Initial setup has not been completed. Please use the setup link from your
<a href="https://app.mapledeploy.ca" class="underline hover:text-warning">MapleDeploy dashboard</a>.
</p>
</div>
</div>
</div>
@else
@if (session('status'))
<div class="mb-6 p-4 bg-success/10 border border-success rounded-lg">
<p class="text-sm text-success">{{ session('status') }}</p>
@ -95,6 +116,7 @@ class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 d
@endforeach
</div>
@endif
@endif {{-- end setup_pending --}}
</div>
</div>
</div>

View file

@ -15,9 +15,10 @@ function getOldOrLocal($key, $localValue)
<div class="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div class="w-full max-w-md space-y-8">
<div class="text-center space-y-2">
<h1 class="!text-5xl font-extrabold tracking-tight text-gray-900 dark:text-white">
Coolify
</h1>
<div class="flex justify-center">
<img src="https://mapledeploy.ca/api/logo/lockup?height=80" alt="MapleDeploy" class="h-12 dark:hidden" />
<img src="https://mapledeploy.ca/api/logo/lockup?height=80&dark=true" alt="MapleDeploy" class="hidden h-12 dark:block" />
</div>
<p class="text-lg dark:text-neutral-400">
Create your account
</p>
@ -96,4 +97,4 @@ class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 d
</div>
</div>
</section>
</x-layout-simple>
</x-layout-simple>

View file

@ -0,0 +1,32 @@
<?php
use App\Models\InstanceSettings;
use Database\Seeders\InstanceSettingsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Vite;
uses(RefreshDatabase::class);
test('MapleDeploy instance settings seeder disables public registration by default', function () {
$this->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);
});

View file

@ -0,0 +1,65 @@
<?php
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'app.maintenance.store' => 'array',
'cache.default' => 'array',
]);
});
test('MapleDeploy root team admins log in to the managed root team', function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$rootTeam = Team::factory()->create([
'id' => 0,
'name' => 'Root Team',
'personal_team' => false,
]);
$user = User::factory()->create([
'email' => 'member@example.com',
]);
expect($user->teams()->where('personal_team', true)->exists())->toBeTrue();
$user->teams()->syncWithoutDetaching([
$rootTeam->id => ['role' => 'admin'],
]);
$response = $this->post('/login', [
'email' => 'member@example.com',
'password' => 'password',
]);
$response->assertRedirect();
expect(session('currentTeam')?->id)->toBe(0);
});
test('MapleDeploy root team admin sessions are repaired from personal team to root team', function () {
$rootTeam = Team::factory()->create([
'id' => 0,
'name' => 'Root Team',
'personal_team' => false,
]);
$user = User::factory()->create([
'email' => 'member@example.com',
]);
$personalTeam = $user->teams()->where('personal_team', true)->firstOrFail();
$user->teams()->syncWithoutDetaching([
$rootTeam->id => ['role' => 'admin'],
]);
$this->actingAs($user);
session(['currentTeam' => $personalTeam]);
app(DecideWhatToDoWithUser::class)->handle(
Request::create('/'),
fn () => response('ok'),
);
expect(session('currentTeam')?->id)->toBe(0);
});

View file

@ -0,0 +1,91 @@
<?php
use App\Actions\Fortify\ResetUserPassword;
use App\Models\InstanceSettings;
use App\Models\User;
use App\Notifications\TransactionalEmails\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Once;
use Illuminate\Validation\ValidationException;
uses(RefreshDatabase::class);
beforeEach(function () {
Notification::fake();
config([
'app.maintenance.driver' => '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();
});

View file

@ -0,0 +1,334 @@
<?php
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\Process\Process;
beforeEach(function () {
$this->originalDatabaseConfig = [
'default' => config('database.default'),
'testing_database' => config('database.connections.testing.database'),
];
$this->originalDatabaseEnvironment = [
'DB_CONNECTION' => [
'env' => $_ENV['DB_CONNECTION'] ?? null,
'server' => $_SERVER['DB_CONNECTION'] ?? null,
'process' => getenv('DB_CONNECTION') === false ? null : getenv('DB_CONNECTION'),
],
'DB_DATABASE' => [
'env' => $_ENV['DB_DATABASE'] ?? null,
'server' => $_SERVER['DB_DATABASE'] ?? null,
'process' => getenv('DB_DATABASE') === false ? null : getenv('DB_DATABASE'),
],
];
$this->databasePath = storage_path('framework/testing-mapledeploy-user-mgmt-'.bin2hex(random_bytes(6)).'.sqlite');
touch($this->databasePath);
config([
'database.default' => 'testing',
'database.connections.testing.database' => $this->databasePath,
]);
$_ENV['DB_CONNECTION'] = 'testing';
$_SERVER['DB_CONNECTION'] = 'testing';
$_ENV['DB_DATABASE'] = $this->databasePath;
$_SERVER['DB_DATABASE'] = $this->databasePath;
putenv('DB_CONNECTION=testing');
putenv("DB_DATABASE={$this->databasePath}");
$GLOBALS['mapledeployUserMgmtDatabasePath'] = $this->databasePath;
DB::purge('testing');
DB::reconnect('testing');
Artisan::call('migrate:fresh', ['--database' => 'testing']);
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
});
afterEach(function () {
DB::disconnect('testing');
DB::purge('testing');
config([
'database.default' => $this->originalDatabaseConfig['default'] ?? null,
'database.connections.testing.database' => $this->originalDatabaseConfig['testing_database'] ?? null,
]);
if (isset($this->databasePath) && file_exists($this->databasePath)) {
unlink($this->databasePath);
}
foreach (($this->originalDatabaseEnvironment ?? []) as $key => $values) {
if ($values['env'] === null) {
unset($_ENV[$key]);
} else {
$_ENV[$key] = $values['env'];
}
if ($values['server'] === null) {
unset($_SERVER[$key]);
} else {
$_SERVER[$key] = $values['server'];
}
if ($values['process'] === null) {
putenv($key);
} else {
putenv("{$key}={$values['process']}");
}
}
unset($GLOBALS['mapledeployUserMgmtDatabasePath']);
});
function runMapledeployUserCommand(array $arguments, string $stdin = ''): array
{
$process = new Process(
[PHP_BINARY, 'artisan', ...$arguments],
base_path(),
[
'APP_ENV' => 'testing',
'APP_KEY' => config('app.key'),
'DB_CONNECTION' => 'testing',
'DB_DATABASE' => $GLOBALS['mapledeployUserMgmtDatabasePath'],
'CACHE_DRIVER' => 'array',
'SESSION_DRIVER' => 'database',
'QUEUE_CONNECTION' => 'sync',
'MAIL_MAILER' => 'array',
'SELF_HOSTED' => 'true',
],
);
$process->setInput($stdin);
$process->setTimeout(30);
$process->run();
$jsonLine = collect(explode("\n", $process->getOutput()))
->map(fn (string $line) => trim($line))
->first(fn (string $line) => str_starts_with($line, '{') && str_ends_with($line, '}'));
return [
'exitCode' => $process->getExitCode(),
'json' => $jsonLine ? json_decode($jsonLine, true, flags: JSON_THROW_ON_ERROR) : null,
'stdout' => $process->getOutput(),
'stderr' => $process->getErrorOutput(),
];
}
test('MapleDeploy user management commands create, list, reset, and revoke users', function () {
$admin = runMapledeployUserCommand([
'mapledeploy:user:create',
'--admin',
'--email=Owner@Example.com',
'--name=Owner',
], "owner-password\n");
expect($admin['exitCode'])->toBe(0)
->and($admin['json']['user'])->toMatchArray([
'id' => 0,
'email' => 'owner@example.com',
'name' => 'Owner',
])
->and((bool) InstanceSettings::findOrFail(0)->is_registration_enabled)->toBeFalse()
->and(Hash::check('owner-password', User::findOrFail(0)->password))->toBeTrue();
$duplicateOwner = runMapledeployUserCommand([
'mapledeploy:user:create',
'--email=OWNER@Example.com',
'--name=Duplicate Owner',
], "duplicate-password\n");
expect($duplicateOwner['exitCode'])->toBe(1)
->and($duplicateOwner['json'])->toBe(['error' => 'EMAIL_EXISTS']);
$member = runMapledeployUserCommand([
'mapledeploy:user:create',
'--email=Member@Example.com',
'--name=Member',
'--team-role=admin',
], "member-password\n");
expect($member['exitCode'])->toBe(0)
->and($member['json']['user']['email'])->toBe('member@example.com');
$memberUser = User::whereEmail('member@example.com')->firstOrFail();
expect($memberUser->teams()->where('teams.id', 0)->first()?->pivot?->role)->toBe('admin')
->and($memberUser->teams()->pluck('teams.id')->all())->toBe([0]);
$list = runMapledeployUserCommand(['mapledeploy:user:list']);
expect($list['exitCode'])->toBe(0)
->and(collect($list['json']['users'])->pluck('email')->all())
->toBe(['owner@example.com', 'member@example.com']);
$resetOtherUser = User::factory()->create();
DB::table('sessions')->insert([
[
'id' => 'member-reset-session',
'user_id' => $memberUser->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('member-reset-payload'),
'last_activity' => now()->timestamp,
],
[
'id' => 'other-reset-session',
'user_id' => $resetOtherUser->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('other-reset-payload'),
'last_activity' => now()->timestamp,
],
]);
$reset = runMapledeployUserCommand([
'mapledeploy:user:set-password',
(string) $memberUser->id,
], "new-member-password\n");
expect($reset['exitCode'])->toBe(0)
->and(Hash::check('new-member-password', $memberUser->fresh()->password))->toBeTrue();
expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(0)
->and(DB::table('sessions')->where('user_id', $resetOtherUser->id)->count())->toBe(1);
DB::table('personal_access_tokens')->insert([
'tokenable_type' => User::class,
'tokenable_id' => $memberUser->id,
'name' => 'e2e-token',
'token' => hash('sha256', 'e2e-token'),
'team_id' => '0',
'abilities' => json_encode(['*'], JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
expect($memberUser->tokens()->count())->toBe(1);
$otherUser = User::factory()->create();
DB::table('sessions')->insert([
[
'id' => 'member-session',
'user_id' => $memberUser->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('member-payload'),
'last_activity' => now()->timestamp,
],
[
'id' => 'other-session',
'user_id' => $otherUser->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('other-payload'),
'last_activity' => now()->timestamp,
],
]);
expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(1);
$revokeRoot = runMapledeployUserCommand(['mapledeploy:user:revoke', '0']);
expect($revokeRoot['exitCode'])->toBe(1)
->and($revokeRoot['json'])->toBe(['error' => 'CANNOT_REVOKE_ROOT_USER']);
$revoke = runMapledeployUserCommand([
'mapledeploy:user:revoke',
(string) $memberUser->id,
]);
expect($revoke['exitCode'])->toBe(0)
->and($revoke['json']['revoked']['email'])->toBe('member@example.com')
->and($memberUser->fresh()->tokens()->count())->toBe(0);
expect(str_starts_with((string) $memberUser->fresh()->remember_token, 'mapledeploy-revoked:'))->toBeTrue();
expect(DB::table('sessions')->where('user_id', $memberUser->id)->count())->toBe(0)
->and(DB::table('sessions')->where('user_id', $otherUser->id)->count())->toBe(1);
$restore = runMapledeployUserCommand([
'mapledeploy:user:set-password',
(string) $memberUser->id,
], "restored-member-password\n");
expect($restore['exitCode'])->toBe(0)
->and(Hash::check('restored-member-password', $memberUser->fresh()->password))->toBeTrue()
->and($memberUser->fresh()->remember_token)->toBeNull();
});
test('MapleDeploy password command can transfer root ownership identity', function () {
runMapledeployUserCommand([
'mapledeploy:user:create',
'--admin',
'--email=old-owner@example.com',
'--name=Old Owner',
], "old-owner-password\n");
DB::table('sessions')->insert([
'id' => 'root-session',
'user_id' => 0,
'ip_address' => '127.0.0.1',
'user_agent' => 'Test Browser',
'payload' => base64_encode('root-payload'),
'last_activity' => now()->timestamp,
]);
$claim = runMapledeployUserCommand([
'mapledeploy:user:set-password',
'0',
'--email=New.Owner@Example.com',
'--name=New Owner',
], "new-owner-password\n");
$root = User::findOrFail(0);
expect($claim['exitCode'])->toBe(0)
->and($claim['json']['user'])->toMatchArray([
'id' => 0,
'email' => 'new.owner@example.com',
'name' => 'New Owner',
])
->and($root->email)->toBe('new.owner@example.com')
->and($root->name)->toBe('New Owner')
->and(Hash::check('new-owner-password', $root->password))->toBeTrue()
->and($root->remember_token)->toBeNull()
->and($root->email_verified_at)->not->toBeNull();
expect(User::whereEmail('old-owner@example.com')->exists())->toBeFalse();
expect(DB::table('sessions')->where('user_id', 0)->count())->toBe(0);
});
test('MapleDeploy password command promotes matched native users to root team admin', function () {
runMapledeployUserCommand([
'mapledeploy:user:create',
'--admin',
'--email=owner@example.com',
'--name=Owner',
], "owner-password\n");
$nativeUser = User::factory()->create([
'email' => 'native-member@example.com',
'name' => 'Native Member',
]);
expect($nativeUser->teams()->where('teams.id', 0)->exists())->toBeFalse();
$reset = runMapledeployUserCommand([
'mapledeploy:user:set-password',
(string) $nativeUser->id,
], "native-member-password\n");
expect($reset['exitCode'])->toBe(0)
->and(Hash::check('native-member-password', $nativeUser->fresh()->password))->toBeTrue()
->and($nativeUser->fresh()->teams()->where('teams.id', 0)->first()?->pivot?->role)->toBe('admin');
});
test('MapleDeploy password command rejects ownership transfer to an existing email', function () {
runMapledeployUserCommand([
'mapledeploy:user:create',
'--admin',
'--email=old-owner@example.com',
'--name=Old Owner',
], "old-owner-password\n");
$existing = User::factory()->create(['email' => 'new.owner@example.com']);
$claim = runMapledeployUserCommand([
'mapledeploy:user:set-password',
'0',
'--email=new.owner@example.com',
'--name=New Owner',
], "new-owner-password\n");
expect($claim['exitCode'])->toBe(1)
->and($claim['json'])->toBe(['error' => 'EMAIL_EXISTS'])
->and(User::findOrFail(0)->email)->toBe('old-owner@example.com')
->and($existing->fresh()->email)->toBe('new.owner@example.com');
});
test('MapleDeploy user creation command reports validation errors as JSON', function () {
$invalid = runMapledeployUserCommand([
'mapledeploy:user:create',
'--email=not-an-email',
'--name=Invalid',
], "short\n");
expect($invalid['exitCode'])->toBe(1)
->and($invalid['json'])->toBe(['error' => 'INVALID_INPUT']);
});