Files
ignacionelson 495f3ae471 Let each role, and each person, choose where they land after signing in
A role now has a start page: the dashboard, files, upload, groups,
clients or the activity log (the last two for staff only). Anyone can
override their role's choice in their profile. The administrator role
takes a start page too, while everything else about it stays locked.

A choice is only used if the account can open that page now. Otherwise
the next one down is tried, ending at the dashboard, so a permission
removed later never lands somebody on a 403. A role cannot be saved
with a start page its own permissions block. A link followed before
signing in still wins, and a waiting getting-started or what's-new page
still goes first.

Applies to password, two-factor and provider sign-ins, and to the site
root for someone already signed in. StartPageTest opens every page for
real, with and without its permission, so the enum cannot drift from
the routes.

Requested by @Zodiac1978 in #1777.
2026-09-13 15:05:40 -03:00

146 lines
5.3 KiB
PHP

<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\StaffAccounts;
use App\Modules\Identity\StartPage;
use App\Modules\Identity\StartPages;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
public function __construct(
private readonly ClientPortalCustomFields $customFields,
private readonly TimezoneRegistry $timezones,
private readonly StaffAccounts $accounts,
private readonly StartPages $startPages,
) {}
/**
* Show the user's profile settings page.
*/
public function edit(Request $request): Response
{
$user = $request->user();
assert($user !== null);
return Inertia::render('settings/profile', [
'mustVerifyEmail' => $user instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
// Resolved, so the picker shows the zone dates are actually
// being rendered in — which for most people is the one their
// browser was detected as, not something they ever chose.
'timezone' => $this->timezones->resolve($user),
'timezones' => $this->timezones->options(),
// Stored, not resolved: an empty choice means "follow my role",
// and the form has to be able to say that rather than show the
// role's page as if the person had picked it.
'start_page' => $user->start_page,
'start_page_options' => $this->startPages->personalOptions($user),
'role_start_page' => (string) __(($this->startPages->roleDefault($user) ?? StartPage::Dashboard)->label($user->type)),
'custom_fields' => $user->isClient() ? $this->customFields->rows(ClientFieldContext::AccountEdit, $user) : [],
'custom_field_values' => $user->isClient() ? $this->customFields->values(ClientFieldContext::AccountEdit, $user) : [],
]);
}
/**
* The account-deletion screen.
*
* Its own page rather than a block under the profile form: this is
* the one irreversible action a person can take on themselves, and it
* should be somewhere you navigate to on purpose instead of somewhere
* you scroll past on the way to saving your email address. The delete
* itself still goes to destroy() below.
*/
public function deleteAccount(): Response
{
return Inertia::render('settings/delete-account', [
'erasureGraceDays' => (int) app(Settings::class)->get(Setting::AccountErasureGraceDays),
]);
}
/**
* Update the user's profile settings.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$user = $request->user();
assert($user !== null);
$validated = $request->validated();
$customFieldValues = $validated['custom_field_values'] ?? [];
unset($validated['custom_field_values']);
$user->fill($validated);
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
$user->save();
if ($user->isClient()) {
$this->customFields->save($user, ClientFieldContext::AccountEdit, $customFieldValues);
}
app(ActivityLogger::class)->log(Action::ProfileUpdated, $user);
return to_route('profile.edit');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validate([
'password' => ['required', 'current_password'],
]);
$user = $request->user();
assert($user !== null);
// The rule every other door into this already asks: Staff update(),
// guardDeletable(), and both role-conversion directions. This one
// did not, and self-deletion is the one door where the account
// being removed is certainly signed in — so the last active
// administrator could take themselves out, leaving no live staff
// row at all. EnsureSetupIsComplete then reopens first-run setup to
// anybody who asks, which is the other half of this and is closed
// below.
$this->accounts->guardLastAdministrator(
$user,
removesAdmin: $this->accounts->isAdministratorRole($user->role_id),
);
Auth::logout();
// Self-deletion: soft delete now, permanent GDPR erasure after
// the disclosed grace period (Setting::AccountErasureGraceDays).
app(ErasureSchedule::class)->apply($user);
$user->delete();
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}