mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-15 09:12:32 -04:00
328 lines
13 KiB
PHP
328 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Team\Member;
|
|
|
|
use App\Enums\AccessLevel;
|
|
use App\Enums\AccessScope;
|
|
use App\Enums\Role;
|
|
use App\Models\Environment;
|
|
use App\Models\Project;
|
|
use App\Models\Scopes\VisibleThroughAccessGrants;
|
|
use App\Models\Team;
|
|
use App\Models\User;
|
|
use App\Services\Access\AccessResolver;
|
|
use App\Services\Access\Grant;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Livewire\Attributes\Computed;
|
|
use Livewire\Attributes\Locked;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Component;
|
|
|
|
/**
|
|
* Edits the access grants (`team_user.access`) of one member with the `member` role.
|
|
*
|
|
* The members page renders one editor; a member row opens it with the `open-member-access` event.
|
|
* A row has one or more targets, and its levels apply to each of them: `team`, or `<scope>:<uuid>`
|
|
* for a project, environment, or resource of the team. Saving stores one grant for each target.
|
|
*
|
|
* The invite form opens the same editor with `open-invitation-access`. Then saving sends the grants
|
|
* back to the invite form with `invitation-access-chosen` and stores nothing.
|
|
*/
|
|
class Access extends Component
|
|
{
|
|
use AuthorizesRequests;
|
|
|
|
#[Locked]
|
|
public int $teamId;
|
|
|
|
#[Locked]
|
|
public ?int $memberId = null;
|
|
|
|
#[Locked]
|
|
public bool $forInvitation = false;
|
|
|
|
public bool $open = false;
|
|
|
|
/** @var list<array{key: string, targets: list<string>, levels: array<string, bool>}> */
|
|
public array $grants = [];
|
|
|
|
public function mount(): void
|
|
{
|
|
$team = currentTeam();
|
|
$this->authorize('manageMembers', $team);
|
|
$this->teamId = $team->id;
|
|
}
|
|
|
|
#[On('open-member-access')]
|
|
public function openFor(int $memberId): void
|
|
{
|
|
$this->authorize('manageMembers', Team::findOrFail($this->teamId));
|
|
$this->memberId = $memberId;
|
|
$this->forInvitation = false;
|
|
$this->resetValidation();
|
|
|
|
$stored = $this->member()->pivot->access;
|
|
$this->grants = $this->rowsFrom($stored === null ? AccessResolver::defaultMemberGrants() : Grant::listFrom($stored));
|
|
$this->open = true;
|
|
}
|
|
|
|
/**
|
|
* @param list<array{scope: string, uuid: string|null, levels: list<string>}> $access
|
|
*/
|
|
#[On('open-invitation-access')]
|
|
public function openForInvitation(array $access): void
|
|
{
|
|
$this->authorize('manageInvitations', Team::findOrFail($this->teamId));
|
|
$this->memberId = null;
|
|
$this->forInvitation = true;
|
|
$this->resetValidation();
|
|
|
|
$this->grants = $this->rowsFrom(Grant::listFrom($access));
|
|
$this->open = true;
|
|
}
|
|
|
|
/**
|
|
* The grants of a preset, or null for an unknown preset. The developer preset needs a project UUID.
|
|
*
|
|
* @return list<Grant>|null
|
|
*/
|
|
public static function presetGrants(string $preset, ?string $projectUuid = null): ?array
|
|
{
|
|
return match ($preset) {
|
|
'viewer' => [Grant::team([AccessLevel::Read])],
|
|
'developer' => filled($projectUuid) ? [new Grant(AccessScope::Project, $projectUuid, [AccessLevel::Write, AccessLevel::Deploy])] : null,
|
|
'operator' => [Grant::team([AccessLevel::Write, AccessLevel::Deploy, AccessLevel::Delete])],
|
|
'full' => [Grant::team([AccessLevel::Root])],
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
public function save(): void
|
|
{
|
|
if ($this->forInvitation) {
|
|
$this->authorize('manageInvitations', Team::findOrFail($this->teamId));
|
|
$grants = $this->validatedGrants();
|
|
$this->dispatch('invitation-access-chosen', access: Grant::listToArray($grants));
|
|
$this->open = false;
|
|
|
|
return;
|
|
}
|
|
|
|
$this->authorize('manageMembers', Team::findOrFail($this->teamId));
|
|
$member = $this->member();
|
|
$grants = $this->validatedGrants();
|
|
|
|
$member->teams()->updateExistingPivot($this->teamId, ['access' => json_encode(Grant::listToArray($grants))]);
|
|
app(AccessResolver::class)->flush();
|
|
|
|
auditLog('ui.team_member.access_updated', [
|
|
'team_id' => $this->teamId,
|
|
'member_id' => $member->id,
|
|
'member_name' => $member->name,
|
|
'member_email' => $member->email,
|
|
'access' => Grant::listToArray($grants),
|
|
]);
|
|
|
|
$this->open = false;
|
|
$this->dispatch('success', 'Access saved.');
|
|
$this->dispatch('reloadWindow');
|
|
}
|
|
|
|
/**
|
|
* Grant targets (`team` or `<scope>:<uuid>`) that exist in a team.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
public static function validTargets(int $teamId): array
|
|
{
|
|
return collect(self::scopeOptionsFor($teamId))->reject(fn (array $option) => $option['header'] ?? false)->pluck('value')->all();
|
|
}
|
|
|
|
/**
|
|
* @return list<Grant>
|
|
*/
|
|
private function validatedGrants(): array
|
|
{
|
|
$validTargets = self::validTargets($this->teamId);
|
|
$grants = [];
|
|
$errors = [];
|
|
foreach ($this->grants as $index => $row) {
|
|
$targets = array_values(array_unique(array_map('strval', (array) ($row['targets'] ?? []))));
|
|
$levels = array_values(array_filter(AccessLevel::cases(), fn (AccessLevel $level) => (bool) ($row['levels'][$level->name] ?? false)));
|
|
|
|
if ($targets === [] || array_diff($targets, $validTargets) !== []) {
|
|
$errors["grants.{$index}.targets"] = 'Choose at least one scope in this team.';
|
|
}
|
|
if ($levels === []) {
|
|
$errors["grants.{$index}.levels"] = 'Choose at least one level.';
|
|
}
|
|
if (isset($errors["grants.{$index}.targets"]) || isset($errors["grants.{$index}.levels"])) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($targets as $target) {
|
|
[$scope, $uuid] = $target === 'team' ? ['team', null] : explode(':', $target, 2);
|
|
$grants[] = new Grant(AccessScope::from($scope), $uuid, $levels);
|
|
}
|
|
}
|
|
if ($errors !== []) {
|
|
throw ValidationException::withMessages($errors);
|
|
}
|
|
|
|
return $grants;
|
|
}
|
|
|
|
/**
|
|
* Every project, environment, and resource of the team, for the scope picker.
|
|
* Queries skip the access-grant scope: only owners and admins open this editor, and they see the whole team.
|
|
*
|
|
* @return list<array{value: string, label: string, header?: bool}>
|
|
*/
|
|
#[Computed]
|
|
public function scopeOptions(): array
|
|
{
|
|
return self::scopeOptionsFor($this->teamId);
|
|
}
|
|
|
|
/**
|
|
* @return list<array{value: string, label: string, header?: bool}>
|
|
*/
|
|
private static function scopeOptionsFor(int $teamId): array
|
|
{
|
|
$projects = Project::withoutGlobalScope(VisibleThroughAccessGrants::class)
|
|
->where('team_id', $teamId)
|
|
->orderBy('name')
|
|
->get(['id', 'uuid', 'name']);
|
|
$environments = Environment::withoutGlobalScope(VisibleThroughAccessGrants::class)
|
|
->whereIn('project_id', $projects->pluck('id'))
|
|
->get(['id', 'uuid', 'name', 'project_id']);
|
|
$projectNames = $projects->pluck('name', 'id');
|
|
$environmentPaths = $environments->mapWithKeys(fn (Environment $environment) => [
|
|
$environment->id => $projectNames[$environment->project_id].' / '.$environment->name,
|
|
]);
|
|
|
|
$resources = collect(AccessResolver::RESOURCE_MODELS)
|
|
->flatMap(fn (string $model) => $model::withoutGlobalScope(VisibleThroughAccessGrants::class)
|
|
->whereIn('environment_id', $environments->pluck('id'))
|
|
->get(['uuid', 'name', 'environment_id']))
|
|
->map(fn ($resource) => ['value' => "resource:{$resource->uuid}", 'label' => $environmentPaths[$resource->environment_id].' / '.$resource->name])
|
|
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
|
|
->values();
|
|
|
|
return [
|
|
['value' => '__team', 'label' => 'Team', 'header' => true],
|
|
['value' => 'team', 'label' => 'Whole team'],
|
|
...($projects->isEmpty() ? [] : [['value' => '__projects', 'label' => 'Projects', 'header' => true]]),
|
|
...$projects->map(fn (Project $project) => ['value' => "project:{$project->uuid}", 'label' => $project->name])->all(),
|
|
...($environments->isEmpty() ? [] : [['value' => '__environments', 'label' => 'Environments', 'header' => true]]),
|
|
...$environments->map(fn (Environment $environment) => ['value' => "environment:{$environment->uuid}", 'label' => $environmentPaths[$environment->id]])
|
|
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)->values()->all(),
|
|
...($resources->isEmpty() ? [] : [['value' => '__resources', 'label' => 'Resources', 'header' => true]]),
|
|
...$resources->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Short description of a member's access for the members list.
|
|
*/
|
|
public static function summary(mixed $storedAccess): string
|
|
{
|
|
if ($storedAccess === null) {
|
|
return 'Team-wide read (default)';
|
|
}
|
|
|
|
$grants = array_filter(Grant::listFrom($storedAccess), fn (Grant $grant) => $grant->levels !== []);
|
|
if ($grants === []) {
|
|
return 'No access';
|
|
}
|
|
|
|
$parts = [];
|
|
foreach ($grants as $grant) {
|
|
if ($grant->scope === AccessScope::Team) {
|
|
$parts[] = 'Team: '.implode(', ', array_map(fn (AccessLevel $level) => Str::lower($level->label()), $grant->levels));
|
|
}
|
|
}
|
|
$counts = array_count_values(array_map(fn (Grant $grant) => $grant->scope->value, $grants));
|
|
foreach ([AccessScope::Project, AccessScope::Environment, AccessScope::Resource] as $scope) {
|
|
if (isset($counts[$scope->value])) {
|
|
$parts[] = $counts[$scope->value].' '.Str::plural($scope->value, $counts[$scope->value]);
|
|
}
|
|
}
|
|
|
|
return implode(' + ', $parts);
|
|
}
|
|
|
|
public function render(): View
|
|
{
|
|
if ($this->forInvitation) {
|
|
$title = 'Access for the invited member';
|
|
$currentSummary = null;
|
|
} elseif ($this->memberId !== null) {
|
|
$member = $this->member();
|
|
$title = "Access for {$member->name}";
|
|
$currentSummary = self::summary($member->pivot->access);
|
|
} else {
|
|
return view('livewire.team.member.access', ['title' => null]);
|
|
}
|
|
|
|
return view('livewire.team.member.access', [
|
|
'title' => $title,
|
|
'team' => Team::findOrFail($this->teamId),
|
|
'currentSummary' => $currentSummary,
|
|
'levels' => collect(AccessLevel::cases())->map(fn (AccessLevel $level) => [
|
|
'name' => $level->name,
|
|
'label' => $level->label(),
|
|
'description' => $level->description(),
|
|
'includes' => array_map(fn (AccessLevel $included) => $included->name, $level->includes()),
|
|
])->all(),
|
|
'presets' => collect(['viewer', 'developer', 'operator', 'full'])->mapWithKeys(fn (string $preset) => [
|
|
$preset => $this->rowsFrom(self::presetGrants($preset, '__project__'))[0],
|
|
])->all(),
|
|
'projectOptions' => collect($this->scopeOptions)->filter(fn (array $option) => str_starts_with($option['value'], 'project:'))->values(),
|
|
]);
|
|
}
|
|
|
|
private function member(): User
|
|
{
|
|
$member = Team::findOrFail($this->teamId)->members()->whereKey($this->memberId)->first();
|
|
abort_if($member === null || $member->pivot->role !== Role::MEMBER->value, 404);
|
|
|
|
return $member;
|
|
}
|
|
|
|
/**
|
|
* Editor rows for stored grants. Grants with the same levels share one row.
|
|
*
|
|
* @param list<Grant> $grants
|
|
* @return list<array{key: string, targets: list<string>, levels: array<string, bool>}>
|
|
*/
|
|
private function rowsFrom(array $grants): array
|
|
{
|
|
return collect($grants)
|
|
->groupBy(fn (Grant $grant) => implode(',', array_map(fn (AccessLevel $level) => $level->name, $grant->levels)))
|
|
->map(fn ($group) => $this->row(
|
|
$group->map(fn (Grant $grant) => $grant->scope === AccessScope::Team ? 'team' : "{$grant->scope->value}:{$grant->uuid}")->unique()->values()->all(),
|
|
$group->first()->levels,
|
|
))
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $targets
|
|
* @param list<AccessLevel> $levels
|
|
* @return array{key: string, targets: list<string>, levels: array<string, bool>}
|
|
*/
|
|
private function row(array $targets, array $levels): array
|
|
{
|
|
return [
|
|
'key' => Str::random(10),
|
|
'targets' => $targets,
|
|
'levels' => collect(AccessLevel::cases())->mapWithKeys(fn (AccessLevel $level) => [$level->name => in_array($level, $levels, true)])->all(),
|
|
];
|
|
}
|
|
}
|