*/ public function candidates(?User $viewer, ?int $excludeId = null): array { return $this->reachableTargets($viewer) ->when($excludeId, fn (Builder $query, int $id) => $query->whereKeyNot($id)) ->with('role') ->orderBy('name') ->get() ->map(function (User $user): array { $role = $user->role; return [ 'id' => $user->id, 'name' => $user->name, 'role' => $user->isClient() ? __('Client') : ($role instanceof Role ? $role->name : __('Staff')), ]; }) ->values() ->all(); } /** * When the account being deleted owns any files/folders, require the * admin to choose what happens to them. Returns an empty array when * there is nothing to decide, so accounts with no content delete * exactly as before. * * @return array{content_action?: string, reassign_to_id?: int} */ public function validate(Request $request, User $target): array { $summary = $this->content->summarize($target); if ($summary['files'] === 0 && $summary['folders'] === 0) { return []; } $viewer = $request->user(); return $request->validate([ 'content_action' => ['required', Rule::in(['cascade_delete', 'reassign'])], 'reassign_to_id' => [ 'required_if:content_action,reassign', 'integer', Rule::notIn([$target->id]), // The same question the picker asks, asked again of what // came back from it. It used to be "exists, and is active", // which is not the boundary the picker documents two // methods up: a client-scoped staff member was shown their // own roster and could name anybody, so deleting a roster // client could hand that client's files and folders to a // client on somebody else's roster — who then reads, edits // and deletes them under the own-upload rules // (GHSA-w29w-pj29-x7ww). // // One predicate for both, rather than a matching pair: a // picker that promises a boundary the write does not keep // is exactly what this was. function (string $attribute, mixed $value, Closure $fail) use ($viewer): void { if (! $this->reachableTargets($viewer)->whereKey($value)->exists()) { // Deliberately the message an id that does not // exist at all would get. "Not yours" and "not // there" have to read the same, or refusing is how // a scoped staff member enumerates the accounts // outside their roster. $fail('validation.exists')->translate(); } }, ], ]); } /** * Every active account $viewer may hand content to: staff, who are * narrowed nowhere in the application, plus the clients * StaffLibraryScope shows them. An unscoped viewer gets everybody, * because clients() returns everybody for them. * * $viewer is null only where the question is about the installation * rather than about a screen — the erasure default in privacy * settings, which is stored once for everybody. * * @return Builder */ private function reachableTargets(?User $viewer): Builder { return User::query() ->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable ->where('type', UserType::Staff) ->orWhereIn('id', $this->scope->clients($for)->select('users.id')))) ->where('active', true); } /** * @param array{content_action?: string, reassign_to_id?: int} $validated */ public function apply(array $validated, User $target, string $name): void { $action = $validated['content_action'] ?? null; if ($action === 'cascade_delete') { $result = $this->content->cascadeDelete($target); $this->activity->log(Action::AccountContentCascadeDeleted, context: ['name' => $name, ...$result]); return; } if ($action === 'reassign' && isset($validated['reassign_to_id'])) { $to = User::findOrFail($validated['reassign_to_id']); $result = $this->content->reassignTo($target, $to); $this->activity->log(Action::AccountContentReassigned, context: ['name' => $name, 'target' => $to->name, ...$result]); } } }