validate([ 'search' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', Rule::in(['active', 'inactive'])], ]); $filters = [ 'search' => $validated['search'] ?? null, 'status' => $validated['status'] ?? null, ]; $clients = User::query() ->where('type', UserType::Client) ->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(User $client): Response { abort_unless($client->isClient(), 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); $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(User $client, TwoFactorAdministration $twoFactor): RedirectResponse { abort_unless($client->isClient(), 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); $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> */ 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> */ 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 $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], ); } } }