Files
projectsend/app/Modules/Clients/Http/Controllers/ClientsController.php
T
ignacionelson e7b5b6a757 Hold client records to the same boundary the rest of the library uses
The other half of the sweep. ClientsController and its API twin checked
`abort_unless($client->isClient(), 404)` and nothing else -- a type
check, not a boundary, which is the phrase #1701 used about the group
membership routes for exactly the same reason.

Measured before the fix, with a client-scoped role holding the client
permissions:

  GET    /clients            every client on the installation, name + email
  GET    /clients/{stranger} 200
  PATCH  /clients/{stranger} 302, name actually changed
  DELETE /clients/{stranger} 302, client gone

The tell was one route over. ClientFilesController::index already draws
this line with StaffLibraryScope::canAssignClient and calls it "the same
boundary StaffLibraryScope enforces everywhere else in the library". Its
neighbours in the same family did not.

So the predicate is not new here. What is new is StaffLibraryScope::clients(),
the listing half of canAssignClient, so a screen narrows by the rule its
own buttons are guarded with instead of restating it -- restating it is
how this went wrong, and how the last four of these went wrong.

Eight actions take it: edit, update, destroy and the two-factor reset on
both surfaces, plus both listings. Answering 404 rather than 403, since a
client outside the roster should not be distinguishable from one that is
not there -- matching the isClient() guard already above it.

Account requests stay installation-wide on purpose: a self-registered
client who has not been approved belongs to nobody yet, so there is no
roster to narrow by and narrowing would empty the screen.

The published API document is unchanged -- both routes already documented
the 404 that the type check produced.
2026-08-26 16:01:27 -03:00

356 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Clients\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Clients\Models\ClientCustomField;
use App\Modules\Clients\Models\ClientCustomFieldValue;
use App\Modules\Clients\Notifications\ClientAccountEditedNotification;
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Pagination;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
/**
* Client management — the recipients files are shared with. Available
* in BOTH editions (clients are never portal-provisioned seats).
* Strictly clients: staff accounts 404 here.
*/
class ClientsController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly Settings $settings,
private readonly ClientStorageUsage $storageUsage,
private readonly DeletedAccountContent $accountContent,
private readonly AccountContentDeletion $accountDeletion,
private readonly StaffLibraryScope $scope,
) {}
public function index(Request $request): Response
{
$validated = $request->validate([
'search' => ['nullable', 'string', 'max:255'],
'status' => ['nullable', Rule::in(['active', 'inactive'])],
]);
$filters = [
'search' => $validated['search'] ?? null,
'status' => $validated['status'] ?? null,
];
// Narrowed by the same rule the buttons on each row are guarded
// with. A client-scoped staff member is not shown the name and
// email of somebody they can reach nothing of — the same thing
// MembershipRequest::approvableBy does for its queue.
$viewer = $request->user();
assert($viewer !== null);
$clients = $this->scope->clients($viewer)
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")))
->when($filters['status'], fn (Builder $query, string $status) => $query->where('active', $status === 'active'))
->orderBy('name')
->paginate(25)
->withQueryString();
$content = $this->accountContent->summarizeMany($clients->pluck('id'));
$clients->through(fn (User $client): array => [
'id' => $client->id,
'name' => $client->name,
'email' => $client->email,
'active' => $client->active,
'account_requested' => $client->account_requested,
'created_at' => $client->created_at?->toIso8601String(),
'content' => $content[$client->id] ?? ['files' => 0, 'folders' => 0],
]);
return Inertia::render('clients/index', [
'clients' => $clients->items(),
'pagination' => Pagination::meta($clients),
'filters' => $filters,
'reassign_candidates' => $this->accountDeletion->candidates(),
]);
}
public function create(): Response
{
return Inertia::render('clients/create', [
'custom_fields' => $this->customFieldDefinitions(),
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate(array_merge([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
], $this->customFieldRules()));
$client = User::create([
'type' => UserType::Client,
'active' => true,
'account_requested' => false,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
// 0 (including an omitted field) means "no custom quota" —
// it inherits Setting::DefaultClientStorageQuotaMb at
// enforcement time (see ClientStorageUsage::quotaMb()), not
// baked in here, so a later change to the site default
// keeps applying to this client automatically.
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
'email_verified_at' => now(),
]);
$this->activity->log(Action::UserCreated, subject: $client);
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientWelcomeNotification);
}
return redirect()->route('clients.edit', $client)->with('success', __('Client created.'));
}
public function edit(Request $request, User $client): Response
{
abort_unless($client->isClient(), 404);
$viewer = $request->user();
assert($viewer !== null);
// A permission is not a boundary: `edit_clients` says this staff
// member manages clients, not that they manage *this* one. The
// same rule ClientFilesController::index applies one route over.
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
return Inertia::render('clients/edit', [
'client' => [
'id' => $client->id,
'name' => $client->name,
'email' => $client->email,
'active' => $client->active,
'account_requested' => $client->account_requested,
'storage_quota_mb' => $client->storage_quota_mb,
'two_factor_enabled' => $client->hasTwoFactorEnabled(),
],
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
'storage_used_mb' => (int) ceil($this->storageUsage->usedBytes($client) / 1024 / 1024),
'custom_fields' => $this->customFieldDefinitions(),
'custom_field_values' => ClientCustomFieldValue::query()
->where('user_id', $client->id)
->pluck('value', 'client_custom_field_id'),
'content' => $this->accountContent->summarize($client),
'reassign_candidates' => $this->accountDeletion->candidates($client->id),
]);
}
public function update(Request $request, User $client): RedirectResponse
{
abort_unless($client->isClient(), 404);
$viewer = $request->user();
assert($viewer !== null);
// A permission is not a boundary: `edit_clients` says this staff
// member manages clients, not that they manage *this* one. The
// same rule ClientFilesController::index applies one route over.
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
$validated = $request->validate(array_merge([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique('users', 'email')->ignore($client->id)],
'active' => ['required', 'boolean'],
'password' => ['nullable', 'confirmed', Password::defaults()],
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
], $this->customFieldRules()));
$wasActive = $client->active;
$passwordChanged = is_string($validated['password'] ?? null) && $validated['password'] !== '';
$client->fill([
'name' => $validated['name'],
'email' => $validated['email'],
'active' => $validated['active'],
// The edit form always submits this field — an empty value
// means the admin explicitly cleared it (ConvertEmptyStringsToNull
// turns it into null before validation), not "leave unchanged".
// 0 = inherit the site default, same as a brand-new client.
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
]);
// Activating a pending account through the edit screen counts as
// approval and clears the request flag.
if ($client->account_requested && $validated['active']) {
$client->account_requested = false;
}
if ($passwordChanged) {
$client->password = $validated['password'];
}
$client->save();
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
$this->activity->log(Action::UserUpdated, subject: $client);
if ($wasActive && ! $client->active) {
$this->activity->log(Action::UserDeactivated, subject: $client);
} elseif (! $wasActive && $client->active) {
$this->activity->log(Action::UserActivated, subject: $client);
}
// Skip a no-op resubmit (same name/email/active, no new password).
if (($client->wasChanged(['name', 'email', 'active']) || $passwordChanged)
&& $this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientAccountEditedNotification);
}
return back()->with('success', __('Client updated.'));
}
/**
* Remove this account's second factor, for the client who has lost
* their authenticator and their recovery codes.
*/
public function destroyTwoFactor(Request $request, User $client, TwoFactorAdministration $twoFactor): RedirectResponse
{
abort_unless($client->isClient(), 404);
$viewer = $request->user();
assert($viewer !== null);
// A permission is not a boundary: `edit_clients` says this staff
// member manages clients, not that they manage *this* one. The
// same rule ClientFilesController::index applies one route over.
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
$twoFactor->reset($client);
return back()->with('success', __('Two-factor authentication removed.'));
}
public function destroy(Request $request, User $client): RedirectResponse
{
abort_unless($client->isClient(), 404);
$viewer = $request->user();
assert($viewer !== null);
// A permission is not a boundary: `edit_clients` says this staff
// member manages clients, not that they manage *this* one. The
// same rule ClientFilesController::index applies one route over.
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
$validated = $this->accountDeletion->validate($request, $client);
$name = $client->name;
$client->delete();
$this->activity->log(Action::UserDeleted, context: ['name' => $name]);
$this->accountDeletion->apply($validated, $client, $name);
return redirect()->route('clients.index')->with('success', __('Client deleted.'));
}
/**
* @return list<array<string, mixed>>
*/
private function customFieldDefinitions(): array
{
return array_values(ClientCustomField::query()
->orderBy('sort_order')
->orderBy('id')
->get()
->map(fn (ClientCustomField $field): array => [
'id' => $field->id,
'label' => $field->label,
'type' => $field->type->value,
'options' => $field->options,
'required' => $field->required,
])
->all());
}
/**
* @return array<string, array<int, mixed>>
*/
private function customFieldRules(): array
{
$rules = [];
foreach (ClientCustomField::query()->get() as $field) {
$key = "custom_field_values.{$field->id}";
// Checkboxes are never hard-required here — "required" only
// drives the asterisk shown on the form, not a forced check.
if ($field->type === ClientCustomFieldType::Checkbox) {
$rules[$key] = ['nullable', 'boolean'];
continue;
}
$rules[$key] = [$field->required ? 'required' : 'nullable', 'string', 'max:2000'];
if ($field->type === ClientCustomFieldType::Select && is_array($field->options)) {
$rules[$key][] = Rule::in($field->options);
}
}
return $rules;
}
/**
* @param array<int, mixed> $values field id => submitted value
*/
private function saveCustomFieldValues(User $client, array $values): void
{
foreach (ClientCustomField::query()->get() as $field) {
$submitted = $values[$field->id] ?? null;
$value = $field->type === ClientCustomFieldType::Checkbox
? ($submitted ? '1' : '0')
: (is_string($submitted) ? $submitted : null);
ClientCustomFieldValue::query()->updateOrCreate(
['client_custom_field_id' => $field->id, 'user_id' => $client->id],
['value' => $value === '' ? null : $value],
);
}
}
}