Files
projectsend/app/Modules/Identity/Http/Controllers/AccountConversionController.php
T
ignacionelson 2eb23dbc07 Stop four comments saying user management is Community-only
It stopped being true in 623ad68, when users.manage opened on both
editions. The code moved and these did not, which is the worst kind of
comment: confidently wrong, and about the very rule a reader comes to
them to learn.

PlatformManaged claimed the tenant's own /users screens stay closed,
directly contradicting the UsersManage comment eleven lines above it.
routes/web.php said the same about the group it gates. The API
controller's docblock opened with "**Community only.**", and the
conversion screen's said a managed installation creates staff accounts
elsewhere.

Each now says what is actually true, and says the division the change
turned on: a platform sells the seats, the tenant decides who sits in
them. What limits a managed plan is the seat cap, not a shut door -- so
the API answers 422 at the limit rather than 403, which is a different
sentence to whoever is reading it.
2026-08-27 22:11:21 -03:00

267 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Identity\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Api\Auth\ApiTokens;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountConversion;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\StaffAccounts;
use App\Modules\Identity\UserType;
use App\Support\Pagination;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
/**
* Moving an account between staff and clients.
*
* Both editions since 2.2.0, by the same route group as every other
* staff-account screen: whoever may create a staff account may promote
* one, and a managed installation limits that by seats rather than by
* closing the screen — AccountConversion asks SeatAllowance on both
* directions, because a promotion spends a staff seat and a demotion
* spends a client one.
*
* The rules live in AccountConversion, which calls StaffAccounts for the
* authority questions. This controller is the request shape and the
* response, plus the counts the confirmation dialog needs in order to say
* what it is about to do — which is the whole safety story of the feature,
* so the numbers are computed in bulk for the page rather than per row.
*/
class AccountConversionController extends Controller
{
public function __construct(
private readonly AccountConversion $conversion,
private readonly StaffAccounts $accounts,
private readonly DeletedAccountContent $accountContent,
private readonly ApiTokens $apiTokens,
) {}
public function index(Request $request): Response
{
$validated = $request->validate([
'direction' => ['nullable', Rule::in(['to_client', 'to_staff'])],
'search' => ['nullable', 'string', 'max:255'],
]);
$direction = $validated['direction'] ?? 'to_client';
$search = $validated['search'] ?? null;
$actor = $this->actor($request);
$accounts = User::query()
->where('type', $direction === 'to_client' ? UserType::Staff : UserType::Client)
->with('role')
->when($search, fn (Builder $query, string $term) => $query->where(fn (Builder $inner) => $inner
->where('name', 'like', "%{$term}%")
->orWhere('email', 'like', "%{$term}%")))
->orderBy('name')
->paginate(25)
->withQueryString();
// One query each for the page, never one per row — this screen
// paginates 25 at a time.
$ids = $accounts->pluck('id');
$content = $this->accountContent->summarizeMany($ids);
$tokens = $this->apiTokens->summarizeMany($ids);
$memberships = $this->groupMembershipCounts($ids);
$shared = $this->fileAssignmentCounts($ids);
$managedBy = $this->managedByCounts($ids);
$manages = $this->managesCounts($ids);
$activeAdminCount = $this->accounts->activeAdministratorCount();
$accounts->through(fn (User $user): array => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role?->name,
'active' => $user->active,
'account_requested' => $user->account_requested,
'is_self' => $user->is($actor),
'is_last_administrator' => $user->active
&& $this->accounts->isAdministratorRole($user->role_id)
&& $activeAdminCount === 1,
// Drives the password field from optional to required, so the
// form says why before it refuses rather than after.
'requires_new_password' => $this->conversion->requiresNewPassword($user),
// Which of the two reasons it is required, so the form can say
// the true one.
'auth_source' => $user->auth_source->value,
// Everything the confirmation dialog enumerates, so it can name
// real numbers rather than say "some data may be affected".
'consequences' => [
'api_tokens' => $tokens[$user->id]['total'] ?? 0,
'assigned_clients' => $manages[$user->id] ?? 0,
'managed_by' => $managedBy[$user->id] ?? 0,
'group_memberships' => $memberships[$user->id] ?? 0,
'files_shared_with_them' => $shared[$user->id] ?? 0,
'content' => $content[$user->id] ?? ['files' => 0, 'folders' => 0],
],
]);
return Inertia::render('users/convert', [
'direction' => $direction,
'filters' => ['search' => $search],
'accounts' => $accounts->items(),
'pagination' => Pagination::meta($accounts),
'roles' => $this->accounts->assignableRoles($actor)
->map(fn (Role $role): array => [
'id' => $role->id,
'name' => $role->name,
'is_system' => $role->is_system,
'client_scoped' => $role->client_scoped,
])->all(),
// Narrowed like `roles` beside it: the picker offers what this
// actor may hand out, which is what store() will accept.
'clients' => User::query()->whereIn('id', $this->accounts->assignableClientIds($actor))->orderBy('name')->get()
->map(fn (User $client): array => ['id' => $client->id, 'name' => $client->name])
->values()->all(),
]);
}
public function store(Request $request, User $user): RedirectResponse
{
$actor = $this->actor($request);
$direction = $request->validate([
'direction' => ['required', Rule::in(['to_client', 'to_staff'])],
])['direction'];
if ($direction === 'to_client') {
abort_unless($user->isStaff(), 404);
$this->conversion->guardToClient($actor, $user);
$this->conversion->toClient($user);
return back()->with('success', __('Account converted to a client.'));
}
abort_unless($user->isClient(), 404);
$this->conversion->guardToStaff($actor, $user);
$validated = $request->validate([
'role_id' => ['required', 'integer', Rule::in($this->accounts->assignableRoleIds($actor))],
'assigned_clients' => ['array'],
'assigned_clients.*' => [
'integer',
// Reach, not a label: see StaffAccounts::assignableClientIds.
Rule::in($this->accounts->assignableClientIds($actor)),
Rule::notIn([$user->id]),
],
// Required only for an account whose credential lives in the
// directory; see AccountConversion::requiresNewPassword(). The
// service refuses it too, so a future caller cannot skip it.
'password' => $this->conversion->requiresNewPassword($user)
? ['required', 'string', Password::defaults()]
: ['nullable', Password::defaults()],
]);
$this->conversion->toStaff(
$user,
(int) $validated['role_id'],
array_values($validated['assigned_clients'] ?? []),
$validated['password'] ?? null,
);
return back()->with('success', __('Account converted to staff.'));
}
/**
* @param Collection<int, int> $ids
* @return array<int, int>
*/
private function groupMembershipCounts(Collection $ids): array
{
return $this->countBy('group_members', 'user_id', $ids);
}
/**
* Files shared directly with this account. `file_assignments` is
* polymorphic — a row points at either a client or a group — so the
* type has to be pinned or group assignments would be counted as if
* they belonged to a user with the same id.
*
* @param Collection<int, int> $ids
* @return array<int, int>
*/
private function fileAssignmentCounts(Collection $ids): array
{
if ($ids->isEmpty()) {
return [];
}
return DB::table('file_assignments')
->where('assignable_type', User::class)
->whereIn('assignable_id', $ids)
->selectRaw('assignable_id as owner_id, count(*) as total')
->groupBy('assignable_id')
->pluck('total', 'owner_id')
->map(fn ($total): int => (int) $total)
->all();
}
/**
* How many staff members have this client in their assigned roster.
*
* @param Collection<int, int> $ids
* @return array<int, int>
*/
private function managedByCounts(Collection $ids): array
{
return $this->countBy('staff_client_assignments', 'client_id', $ids);
}
/**
* How many clients this staff member manages.
*
* @param Collection<int, int> $ids
* @return array<int, int>
*/
private function managesCounts(Collection $ids): array
{
return $this->countBy('staff_client_assignments', 'staff_id', $ids);
}
/**
* One grouped query per concern, for the whole page. A per-row count
* here would be an N+1 on a screen that paginates 25 at a time.
*
* @param Collection<int, int> $ids
* @return array<int, int>
*/
private function countBy(string $table, string $column, Collection $ids): array
{
if ($ids->isEmpty()) {
return [];
}
return DB::table($table)
->whereIn($column, $ids)
->selectRaw("{$column} as owner_id, count(*) as total")
->groupBy($column)
->pluck('total', 'owner_id')
->map(fn ($total): int => (int) $total)
->all();
}
private function actor(Request $request): User
{
$actor = $request->user();
assert($actor instanceof User);
return $actor;
}
}