mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-18 17:45:09 +00:00
e7b5b6a757
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.
356 lines
14 KiB
PHP
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],
|
|
);
|
|
}
|
|
}
|
|
}
|