mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
62c763d04e
Setting::DefaultClientStorageQuotaMb defaults to 0, and 0 means
unlimited. That is the right default for somebody setting up their own
installation and the wrong one for an installation a platform operates
on other people's behalf: an account that arrived without an explicit
quota has no ceiling at all, and it does not have to be an account the
platform created.
So a platform may set a floor in the environment
(PROJECTSEND_PLATFORM_DEFAULT_CLIENT_QUOTA_MB), exactly as it sets the
seat caps, and for the same reason those are not settings: it is the
shape of what was sold rather than a preference the installation's
administrator is expressing. It applies only where the setting says
nothing, so an administrator who chose a number keeps it, and an install
with no platform behind it is unaffected.
ClientStorageUsage::defaultQuotaMb() is where the three sources resolve,
and every screen that presents the answer now reads it there:
- The client create and edit screens. The edit screen mirrors that
resolution client-side to draw the usage bar, so handed the raw
setting on a floored installation it computed an effective quota of
zero, printed "unlimited" and hid the bar entirely -- for a client
whose next upload was about to be rejected for exceeding a limit the
screen said did not exist.
- projectsend:status, which gains clients_can_register and
default_client_storage_quota_mb. Both defaults are the permissive
ones, both are invisible from outside, and a document reporting the
setting while uploads obeyed the floor would say the ceiling was
missing on an installation that has one.
The Client settings form deliberately still reads the raw setting: that
field is read and written back on save, so prefilling it with the floor
would write the platform's number into the setting as the
administrator's own choice, where it would outlive the floor.
417 lines
17 KiB
PHP
417 lines
17 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\ClientAccounts;
|
|
use App\Modules\Clients\ClientCustomFieldType;
|
|
use App\Modules\Clients\ClientStorageUsage;
|
|
use App\Modules\Files\Access\StaffLibraryScope;
|
|
use App\Modules\Platform\Seats\SeatAllowance;
|
|
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\Erasure\AvailableEmailRule;
|
|
use App\Modules\Identity\Erasure\ErasureSchedule;
|
|
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
|
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\Support\Facades\DB;
|
|
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,
|
|
private readonly SeatAllowance $seats,
|
|
private readonly ClientAccounts $clients,
|
|
private readonly ErasureSchedule $erasure,
|
|
) {}
|
|
|
|
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,
|
|
// Only for somebody who may actually reassign: the picker is
|
|
// part of the delete dialog, and React filtering it out of the
|
|
// page is not the same as it never being on the page.
|
|
'reassign_candidates' => $viewer->can('delete_clients')
|
|
? $this->accountDeletion->candidates($viewer)
|
|
: [],
|
|
// Null on a self-hosted install: no limit, nothing to say.
|
|
'seats' => $this->seats->clientState(),
|
|
]);
|
|
}
|
|
|
|
public function create(): RedirectResponse|Response
|
|
{
|
|
// The same courtesy UsersController::create() does: a full
|
|
// installation is an ordinary state on a managed plan, so say so
|
|
// before somebody fills in a form that cannot be submitted. The
|
|
// guard in store() is still the rule; this is only the door.
|
|
$seats = $this->seats->clientState();
|
|
|
|
if ($seats !== null && $seats['full']) {
|
|
return redirect()->route('clients.index')->with('error', $seats['message']);
|
|
}
|
|
|
|
return Inertia::render('clients/create', [
|
|
'custom_fields' => $this->customFieldDefinitions(),
|
|
// The resolved default, not the raw setting: a platform can put
|
|
// a floor under it from the environment, and both screens
|
|
// present this as what will actually happen rather than as a
|
|
// value being edited. The edit screen mirrors quotaMb()'s
|
|
// resolution client-side to draw the usage bar, and handing it
|
|
// the effective number is what keeps that mirror correct
|
|
// without it having to know floors exist.
|
|
//
|
|
// The Client settings form deliberately still reads the raw
|
|
// setting (ClientSettingsController): that field is edited and
|
|
// saved back, so prefilling it with a floor would write the
|
|
// platform's number into the setting as the administrator's own
|
|
// choice, where it would outlive the floor.
|
|
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
|
|
]);
|
|
}
|
|
|
|
public function store(Request $request): RedirectResponse
|
|
{
|
|
$validated = $request->validate(array_merge([
|
|
'name' => ['required', 'string', 'max:255'],
|
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
|
'password' => ['required', 'confirmed', Password::defaults()],
|
|
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
|
], $this->customFieldRules()));
|
|
|
|
// The seat guard, the type, the role, and the quota's "0 means
|
|
// inherit the site default" all live in ClientAccounts, shared
|
|
// with the API and the control plane. A client created here is
|
|
// approved by construction, so it counts against the cap
|
|
// immediately — unlike a self-registration awaiting a decision.
|
|
// The welcome waits until the custom fields are saved below.
|
|
$client = $this->clients->create(
|
|
name: $validated['name'],
|
|
email: $validated['email'],
|
|
password: $validated['password'],
|
|
storageQuotaMb: $validated['storage_quota_mb'] ?? 0,
|
|
welcome: false,
|
|
);
|
|
|
|
$creator = $request->user();
|
|
assert($creator !== null);
|
|
|
|
// A client-scoped creator would otherwise lose the client they just
|
|
// made. guardTarget() answers 404 for anything off their roster, so
|
|
// the record they created is not theirs to open, and
|
|
// StaffLibraryScope::clients() leaves it out of their list as well —
|
|
// the client exists, is welcomed by email, and is invisible to the
|
|
// person who made it. Their own roster is where a client they
|
|
// created belongs; an unscoped creator has no roster to add to.
|
|
if ($creator->isClientScoped()) {
|
|
$creator->assignedClients()->attach($client->id);
|
|
}
|
|
|
|
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
|
|
|
|
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
|
$client->notify(new ClientWelcomeNotification);
|
|
}
|
|
|
|
// A role can hold create_clients without edit_clients, and the edit
|
|
// page this used to land on unconditionally answers such a role
|
|
// with a 403 — after the client was created, logged and welcomed.
|
|
// Fall back to the create form: it shares this route's own gate, so
|
|
// it is reachable by exactly whoever just created the record, and
|
|
// the success toast shows there.
|
|
$target = $creator->can('edit_clients')
|
|
? redirect()->route('clients.edit', $client)
|
|
: redirect()->route('clients.create');
|
|
|
|
return $target->with('success', __('Client created.'));
|
|
}
|
|
|
|
/**
|
|
* The one question every route binding a client has to ask.
|
|
*
|
|
* 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. 404
|
|
* rather than 403, so a client outside the roster is not
|
|
* distinguishable from one that is not there.
|
|
*/
|
|
private function guardTarget(Request $request, User $client): void
|
|
{
|
|
abort_unless($client->isClient(), 404);
|
|
|
|
$viewer = $request->user();
|
|
assert($viewer !== null);
|
|
|
|
abort_unless($this->scope->canAssignClient($viewer, $client), 404);
|
|
}
|
|
|
|
public function edit(Request $request, User $client): Response
|
|
{
|
|
$this->guardTarget($request, $client);
|
|
|
|
|
|
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(),
|
|
],
|
|
// Resolved, not raw — see create() above.
|
|
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
|
|
'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' => $request->user()?->can('delete_clients') === true
|
|
? $this->accountDeletion->candidates($request->user(), $client->id)
|
|
: [],
|
|
]);
|
|
}
|
|
|
|
public function update(Request $request, User $client): RedirectResponse
|
|
{
|
|
$this->guardTarget($request, $client);
|
|
|
|
|
|
$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 — which is the moment a
|
|
// seat is spent, so the cap is asked here for the same reason
|
|
// AccountRequestsController::approve() asks it one screen over.
|
|
// Inside the branch, not above it: an installation at its cap must
|
|
// still be able to rename a client it already has.
|
|
if ($client->account_requested && $validated['active']) {
|
|
$this->seats->guardClient('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
|
|
{
|
|
$this->guardTarget($request, $client);
|
|
|
|
|
|
$twoFactor->reset($client);
|
|
|
|
return back()->with('success', __('Two-factor authentication removed.'));
|
|
}
|
|
|
|
public function destroy(Request $request, User $client): RedirectResponse
|
|
{
|
|
$this->guardTarget($request, $client);
|
|
|
|
|
|
$validated = $this->accountDeletion->validate($request, $client);
|
|
|
|
// Soft-deleting the account and disposing of its files are two
|
|
// separate writes; keep them in one transaction so a failure in the
|
|
// second (e.g. the reassignment target deleted between validation
|
|
// and apply()'s findOrFail) cannot leave the account deleted with
|
|
// its content still pointing at it.
|
|
//
|
|
// The erasure stamp goes inside for the same reason: a deletion
|
|
// that rolls back must not leave a live account carrying a date
|
|
// on which it would be erased.
|
|
DB::transaction(function () use ($validated, $client): void {
|
|
$name = $client->name;
|
|
$this->erasure->apply($client);
|
|
$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],
|
|
);
|
|
}
|
|
}
|
|
}
|