feat(auth): add dashboard-managed Coolify users
This commit is contained in:
parent
c8bacd9118
commit
fbc92e6fcc
23 changed files with 890 additions and 66 deletions
|
|
@ -36,14 +36,6 @@ public function create(array $input): User
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
if (User::count() == 0) {
|
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
|
// If this is the first user, make them the root user
|
||||||
// Team is already created in the database/seeders/ProductionSeeder.php
|
// Team is already created in the database/seeders/ProductionSeeder.php
|
||||||
$user = (new User)->forceFill([
|
$user = (new User)->forceFill([
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||||
|
|
||||||
class ResetUserPassword implements ResetsUserPasswords
|
class ResetUserPassword implements ResetsUserPasswords
|
||||||
|
|
@ -17,6 +18,13 @@ class ResetUserPassword implements ResetsUserPasswords
|
||||||
*/
|
*/
|
||||||
public function reset(User $user, array $input): void
|
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, [
|
Validator::make($input, [
|
||||||
'password' => ['required', Password::defaults(), 'confirmed'],
|
'password' => ['required', Password::defaults(), 'confirmed'],
|
||||||
])->validate();
|
])->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -79,6 +79,11 @@ public function forgot_password(Request $request)
|
||||||
return response()->json(['message' => 'Transactional emails are not active'], 400);
|
return response()->json(['message' => 'Transactional emails are not active'], 400);
|
||||||
}
|
}
|
||||||
$request->validate([Fortify::email() => 'required|email']);
|
$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(
|
$status = Password::broker(config('fortify.passwords'))->sendResetLink(
|
||||||
$request->only(Fortify::email())
|
$request->only(Fortify::email())
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,12 @@ public function callback(string $provider)
|
||||||
}
|
}
|
||||||
$email = strtolower($email);
|
$email = strtolower($email);
|
||||||
$user = User::whereEmail($email)->first();
|
$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) {
|
if (! $user) {
|
||||||
$settings = instanceSettings();
|
$settings = instanceSettings();
|
||||||
if (! $settings->is_registration_enabled) {
|
if (! $settings->is_registration_enabled) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
|
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
|
||||||
use App\Http\Middleware\PreventRequestsDuringMaintenance;
|
use App\Http\Middleware\PreventRequestsDuringMaintenance;
|
||||||
use App\Http\Middleware\RedirectIfAuthenticated;
|
use App\Http\Middleware\RedirectIfAuthenticated;
|
||||||
|
use App\Http\Middleware\RejectMapledeployRevokedUser;
|
||||||
use App\Http\Middleware\TrimStrings;
|
use App\Http\Middleware\TrimStrings;
|
||||||
use App\Http\Middleware\TrustHosts;
|
use App\Http\Middleware\TrustHosts;
|
||||||
use App\Http\Middleware\TrustProxies;
|
use App\Http\Middleware\TrustProxies;
|
||||||
|
|
@ -71,6 +72,7 @@ class Kernel extends HttpKernel
|
||||||
ShareErrorsFromSession::class,
|
ShareErrorsFromSession::class,
|
||||||
VerifyCsrfToken::class,
|
VerifyCsrfToken::class,
|
||||||
SubstituteBindings::class,
|
SubstituteBindings::class,
|
||||||
|
RejectMapledeployRevokedUser::class,
|
||||||
CheckForcePasswordReset::class,
|
CheckForcePasswordReset::class,
|
||||||
DecideWhatToDoWithUser::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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -289,9 +289,19 @@ public function sendVerificationEmail()
|
||||||
|
|
||||||
public function sendPasswordResetNotification($token): void
|
public function sendPasswordResetNotification($token): void
|
||||||
{
|
{
|
||||||
|
if ($this->isMapledeployRevoked()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this?->notify(new TransactionalEmailsResetPassword($token));
|
$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()
|
public function isAdmin()
|
||||||
{
|
{
|
||||||
return $this->role() === 'admin' || $this->role() === 'owner';
|
return $this->role() === 'admin' || $this->role() === 'owner';
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
use App\Actions\Fortify\UpdateUserPassword;
|
use App\Actions\Fortify\UpdateUserPassword;
|
||||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||||
use App\Models\OauthSetting;
|
use App\Models\OauthSetting;
|
||||||
|
use App\Models\TeamInvitation;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -51,17 +52,8 @@ public function boot(): void
|
||||||
|
|
||||||
$isFirstUser = User::count() === 0;
|
$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', [
|
return view('auth.register', [
|
||||||
'isFirstUser' => $isFirstUser,
|
'isFirstUser' => $isFirstUser,
|
||||||
'setupToken' => request()->query('setup_token'),
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -69,16 +61,11 @@ public function boot(): void
|
||||||
$settings = instanceSettings();
|
$settings = instanceSettings();
|
||||||
$enabled_oauth_providers = OauthSetting::where('enabled', true)->get();
|
$enabled_oauth_providers = OauthSetting::where('enabled', true)->get();
|
||||||
$users = User::count();
|
$users = User::count();
|
||||||
if ($users == 0) {
|
// MapleDeploy branding: public registration is disabled by default
|
||||||
// MapleDeploy: don't redirect to register if setup token is required
|
// because the dashboard creates the first admin over SSH. Do not
|
||||||
if ($settings->setup_token) {
|
// redirect to /register in that fail-closed state, or fresh/failed
|
||||||
return view('auth.login', [
|
// provisioning loops between login and registration.
|
||||||
'setup_pending' => true,
|
if ($users == 0 && $settings->is_registration_enabled) {
|
||||||
'is_registration_enabled' => false,
|
|
||||||
'enabled_oauth_providers' => collect(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('register');
|
return redirect()->route('register');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +86,7 @@ public function boot(): void
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
// Check if user has a pending invitation they haven't accepted yet
|
// 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()) {
|
if ($invitation && $invitation->isValid()) {
|
||||||
// User is logging in for the first time after being invited
|
// User is logging in for the first time after being invited
|
||||||
// Attach them to the invited team if not already attached
|
// Attach them to the invited team if not already attached
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@
|
||||||
|
|
||||||
'testing' => [
|
'testing' => [
|
||||||
'driver' => 'sqlite',
|
'driver' => 'sqlite',
|
||||||
'database' => ':memory:',
|
'database' => env('DB_DATABASE', ':memory:'),
|
||||||
'prefix' => '',
|
'prefix' => '',
|
||||||
'foreign_key_constraints' => true,
|
'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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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([
|
InstanceSettings::create([
|
||||||
'id' => 0,
|
'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(),
|
'is_api_enabled' => isDev(),
|
||||||
'smtp_enabled' => true,
|
'smtp_enabled' => true,
|
||||||
'smtp_host' => 'coolify-mail',
|
'smtp_host' => 'coolify-mail',
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,9 @@ public function run(): void
|
||||||
if (InstanceSettings::find(0) == null) {
|
if (InstanceSettings::find(0) == null) {
|
||||||
InstanceSettings::create([
|
InstanceSettings::create([
|
||||||
'id' => 0,
|
'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.register_now": "Register",
|
||||||
"auth.logout": "Logout",
|
"auth.logout": "Logout",
|
||||||
"auth.register": "Register",
|
"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.reset_password": "Reset password",
|
||||||
"auth.failed": "These credentials do not match our records.",
|
"auth.failed": "These credentials do not match our records.",
|
||||||
"auth.failed.callback": "Failed to process callback from login provider.",
|
"auth.failed.callback": "Failed to process callback from login provider.",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
"auth.register_now": "S'enregistrer",
|
"auth.register_now": "S'enregistrer",
|
||||||
"auth.logout": "Déconnexion",
|
"auth.logout": "Déconnexion",
|
||||||
"auth.register": "S'enregistrer",
|
"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.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": "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.",
|
"auth.failed.callback": "Erreur lors du processus de retour de la plateforme de connexion.",
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,6 @@ function getOldOrLocal($key, $localValue)
|
||||||
|
|
||||||
<form action="/register" method="POST" class="flex flex-col gap-4">
|
<form action="/register" method="POST" class="flex flex-col gap-4">
|
||||||
@csrf
|
@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 }}"
|
<x-forms.input id="name" required type="text" name="name" value="{{ $name }}"
|
||||||
label="{{ __('input.name') }}" />
|
label="{{ __('input.name') }}" />
|
||||||
<x-forms.input id="email" required type="email" name="email" value="{{ $email }}"
|
<x-forms.input id="email" required type="email" name="email" value="{{ $email }}"
|
||||||
|
|
|
||||||
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