feat(auth): add dashboard-managed Coolify users
All checks were successful
Build MapleDeploy Coolify Image / build (push) Successful in 1m26s
All checks were successful
Build MapleDeploy Coolify Image / build (push) Successful in 1m26s
This commit is contained in:
parent
4ca700dfe7
commit
e3cb2675dd
25 changed files with 893 additions and 164 deletions
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Jobs\NotifySetupCompleteJob;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
|
@ -36,14 +35,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([
|
||||
|
|
@ -58,18 +49,7 @@ public function create(array $input): User
|
|||
// Disable registration after first user is created
|
||||
$settings = instanceSettings();
|
||||
$settings->is_registration_enabled = false;
|
||||
|
||||
// MapleDeploy: notify control plane that setup is complete
|
||||
// Capture token before clearing so the job can authenticate with it
|
||||
$callbackUrl = $settings->setup_callback_url;
|
||||
$token = $settings->setup_token;
|
||||
$settings->setup_token = null;
|
||||
$settings->setup_callback_url = null;
|
||||
$settings->save();
|
||||
|
||||
if ($callbackUrl && $token) {
|
||||
NotifySetupCompleteJob::dispatch($token, $callbackUrl);
|
||||
}
|
||||
} else {
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
140
app/Console/Commands/Mapledeploy/UserCreate.php
Normal file
140
app/Console/Commands/Mapledeploy/UserCreate.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?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();
|
||||
$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;
|
||||
}
|
||||
}
|
||||
40
app/Console/Commands/Mapledeploy/UserList.php
Normal file
40
app/Console/Commands/Mapledeploy/UserList.php
Normal 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;
|
||||
}
|
||||
}
|
||||
55
app/Console/Commands/Mapledeploy/UserRevoke.php
Normal file
55
app/Console/Commands/Mapledeploy/UserRevoke.php
Normal 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;
|
||||
}
|
||||
}
|
||||
88
app/Console/Commands/Mapledeploy/UserSetPassword.php
Normal file
88
app/Console/Commands/Mapledeploy/UserSetPassword.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?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\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');
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +78,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())
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
use App\Http\Middleware\EnsureMcpEnabled;
|
||||
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;
|
||||
|
|
@ -70,6 +71,7 @@ class Kernel extends HttpKernel
|
|||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
RejectMapledeployRevokedUser::class,
|
||||
CheckForcePasswordReset::class,
|
||||
DecideWhatToDoWithUser::class,
|
||||
|
||||
|
|
|
|||
37
app/Http/Middleware/RejectMapledeployRevokedUser.php
Normal file
37
app/Http/Middleware/RejectMapledeployRevokedUser.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Notify MapleDeploy that the first user has registered on this Coolify instance.
|
||||
*
|
||||
* Sends the setup token as a Bearer token so MapleDeploy can verify authenticity
|
||||
* and clear its stored copy. The token acts as a one-time shared secret.
|
||||
*/
|
||||
class NotifySetupCompleteJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 5;
|
||||
|
||||
public array $backoff = [10, 30, 60, 120, 300];
|
||||
|
||||
public int $maxExceptions = 5;
|
||||
|
||||
public function __construct(
|
||||
public string $setupToken,
|
||||
public string $callbackUrl
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$response = Http::withToken($this->setupToken)
|
||||
->timeout(15)
|
||||
->post($this->callbackUrl);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::warning('Setup-complete callback failed', [
|
||||
'status' => $response->status(),
|
||||
'url' => $this->callbackUrl,
|
||||
]);
|
||||
|
||||
// Throw so the job retries
|
||||
throw new \RuntimeException(
|
||||
"Setup-complete callback returned HTTP {$response->status()}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -269,9 +269,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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -48,14 +48,14 @@
|
|||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => 'prefer',
|
||||
'options' => [
|
||||
(defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? \Pdo\Pgsql::ATTR_DISABLE_PREPARES : \PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false),
|
||||
],
|
||||
'options' => defined('\PDO::PGSQL_ATTR_DISABLE_PREPARES')
|
||||
? [PDO::PGSQL_ATTR_DISABLE_PREPARES => env('DB_DISABLE_PREPARES', false)]
|
||||
: [],
|
||||
],
|
||||
|
||||
'testing' => [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'database' => env('DB_DATABASE', ':memory:'),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => true,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('instance_settings', function (Blueprint $table) {
|
||||
$table->string('setup_token')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('instance_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('setup_token');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('instance_settings', function (Blueprint $table) {
|
||||
$table->string('setup_callback_url')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('instance_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('setup_callback_url');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -47,6 +47,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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,9 +53,6 @@ function getOldOrLocal($key, $localValue)
|
|||
|
||||
<form action="/register" method="POST" class="flex flex-col gap-4">
|
||||
@csrf
|
||||
@if (isset($setupToken))
|
||||
<input type="hidden" name="setup_token" value="{{ $setupToken }}" />
|
||||
@endif
|
||||
<x-forms.input id="name" required type="text" name="name" value="{{ $name }}"
|
||||
label="{{ __('input.name') }}" />
|
||||
<x-forms.input id="email" required type="email" name="email" value="{{ $email }}"
|
||||
|
|
@ -100,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>
|
||||
|
|
|
|||
32
tests/Feature/MapledeployInstanceSettingsSeederTest.php
Normal file
32
tests/Feature/MapledeployInstanceSettingsSeederTest.php
Normal 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);
|
||||
});
|
||||
91
tests/Feature/MapledeployRevokedPasswordResetTest.php
Normal file
91
tests/Feature/MapledeployRevokedPasswordResetTest.php
Normal 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();
|
||||
});
|
||||
310
tests/Feature/MapledeployUserManagementCommandsTest.php
Normal file
310
tests/Feature/MapledeployUserManagementCommandsTest.php
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
<?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');
|
||||
|
||||
$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']);
|
||||
});
|
||||
Loading…
Reference in a new issue