Files
projectsend/app/Modules/Identity/Http/Controllers/SetupController.php
T
ignacionelson 6560346280 Mark the first administrator's address verified, as intended
The last two paths that passed email_verified_at into User::create() and
lost it: the setup screen, and projectsend:admin for a container that
comes up from environment variables. It is deliberately absent from
$fillable, so mass assignment drops it without a word, and both meant to
set it.

The intent is plain in both cases — the first administrator typed their
own address into the form in front of them, and whoever provisioned the
container supplied it themselves. There is nobody to confirm it to.

Inert today, since MustVerifyEmail is not enabled on the model, but the
column is what a later switch would read: turning verification on would
have locked out the one account that cannot be helped by another
administrator.

Both are now pinned by a test that fails when the forceFill is removed.
StaffAccounts had already fixed this for staff and named the rest; with
client accounts done earlier today, that list is empty.

Also says on User::$fillable what absence from it buys and what it does
not. It stops a request smuggling a value in; it does not tell code that
meant to set the value that it failed. Four separate paths made the same
mistake against the same comment.
2026-09-08 17:07:35 -03:00

121 lines
4.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Identity\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Onboarding\InstallationWelcome;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
/**
* First-run setup: create the initial staff administrator. Only reachable
* while no staff user exists; afterwards the routes bounce home.
*/
class SetupController extends Controller
{
public function __construct(
private readonly Settings $settings,
private readonly ActivityLogger $activity,
private readonly InstallationWelcome $welcome,
) {}
public function show(): Response|RedirectResponse
{
if ($this->setupIsComplete()) {
return redirect()->route('home');
}
return Inertia::render('setup');
}
public function store(Request $request): RedirectResponse
{
if ($this->setupIsComplete()) {
return redirect()->route('home');
}
$validated = $request->validate([
'site_name' => ['required', 'string', 'max:255'],
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$this->settings->set(Setting::SiteName, $validated['site_name']);
$admin = User::create([
'type' => UserType::Staff,
'active' => true,
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
]);
// forceFill, not part of the create() array: email_verified_at is
// deliberately absent from User::$fillable, so mass assignment
// dropped it in silence and this account was never marked
// verified. The first administrator typed their own address into
// the form in front of them; there is nobody to confirm it to.
// (Inert today, since MustVerifyEmail is not enabled on the model,
// but the column is what a later switch would read.)
$admin->forceFill(['email_verified_at' => now()])->save();
// v1 logged installation as action 0; setup is a recorded action.
$this->activity->log(Action::SetupCompleted, $admin);
$this->activity->log(Action::UserCreated, $admin, $admin);
if ($this->settings->get(Setting::AdminNotificationEmails) === []) {
$this->settings->set(Setting::AdminNotificationEmails, [$admin->email]);
}
// They will be shown around the first time they sign in — which is
// the next thing that happens, since setup deliberately does not
// log anybody in.
$this->welcome->raise();
// Deliberately no auto-login: the new administrator proves their
// credentials at the login form, which also confirms they work.
return redirect()->route('setup.success')->with('setup_completed', true);
}
public function success(Request $request): Response|RedirectResponse
{
if (! $this->setupIsComplete()) {
return redirect()->route('setup');
}
if (! $request->session()->get('setup_completed')) {
return redirect()->route('login');
}
return Inertia::render('setup-success');
}
/**
* Trashed staff count, for the reason EnsureSetupIsComplete gives:
* this asks whether the installation was ever set up, and store()
* below is the door a stranger walks through if the answer is wrong.
* The middleware and this must agree — one of them saying "not set
* up" while the other says "set up" is either a redirect loop or an
* open form.
*/
private function setupIsComplete(): bool
{
return User::query()->withTrashed()->where('type', UserType::Staff)->exists();
}
}