mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(access): set member grants when inviting
This commit is contained in:
@@ -43,6 +43,18 @@ enum AccessLevel: string
|
||||
};
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Read => 'See resources, logs, and settings. Secrets stay hidden.',
|
||||
self::ReadSensitive => 'Also see environment variables and other secrets.',
|
||||
self::Deploy => 'Deploy, start, stop, and restart.',
|
||||
self::Write => 'Change settings and create resources.',
|
||||
self::Delete => 'Delete resources.',
|
||||
self::Root => 'Every level, with no limits.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse stored level or token ability strings. Unknown values are ignored.
|
||||
*
|
||||
|
||||
@@ -134,7 +134,7 @@ class Controller extends BaseController
|
||||
$team = $invitation->team;
|
||||
$alreadyMember = $user->teams()->where('team_id', $team->id)->exists();
|
||||
if (! $alreadyMember) {
|
||||
$user->teams()->attach($team->id, ['role' => $invitation->role]);
|
||||
$user->teams()->attach($team->id, $invitation->membershipAttributes());
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
@@ -259,7 +259,7 @@ class Controller extends BaseController
|
||||
|
||||
return redirect()->route('team.index');
|
||||
}
|
||||
$user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
|
||||
$user->teams()->attach($invitation->team->id, $invitation->membershipAttributes());
|
||||
$invitation->delete();
|
||||
$this->auditInvitationAccepted($user, $invitation, alreadyMember: false);
|
||||
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
namespace App\Livewire\Team;
|
||||
|
||||
use App\Enums\AccessScope;
|
||||
use App\Livewire\Team\Member\Access;
|
||||
use App\Models\TeamInvitation;
|
||||
use App\Models\User;
|
||||
use App\Services\Access\AccessResolver;
|
||||
use App\Services\Access\Grant;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class InviteLink extends Component
|
||||
@@ -19,6 +24,13 @@ class InviteLink extends Component
|
||||
|
||||
public string $role = 'member';
|
||||
|
||||
/**
|
||||
* Grants for an invited member, set by the access editor. Checked again on invite.
|
||||
*
|
||||
* @var list<array{scope: string, uuid: string|null, levels: list<string>}>
|
||||
*/
|
||||
public array $grants = [];
|
||||
|
||||
protected $rules = [
|
||||
'email' => 'required|email',
|
||||
'role' => 'required|string',
|
||||
@@ -27,6 +39,16 @@ class InviteLink extends Component
|
||||
public function mount()
|
||||
{
|
||||
$this->email = isDev() ? 'test3@example.com' : '';
|
||||
$this->grants = Grant::listToArray(AccessResolver::defaultMemberGrants());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{scope: string, uuid: string|null, levels: list<string>}> $access
|
||||
*/
|
||||
#[On('invitation-access-chosen')]
|
||||
public function setGrants(array $access): void
|
||||
{
|
||||
$this->grants = Grant::listToArray(Grant::listFrom($access));
|
||||
}
|
||||
|
||||
public function viaEmail()
|
||||
@@ -64,6 +86,19 @@ class InviteLink extends Component
|
||||
throw new \Exception('Admins cannot invite owners.');
|
||||
}
|
||||
|
||||
$access = null;
|
||||
if ($this->role === 'member') {
|
||||
$grants = Grant::listFrom($this->grants);
|
||||
$validTargets = Access::validTargets(currentTeam()->id);
|
||||
foreach ($grants as $grant) {
|
||||
$target = $grant->scope === AccessScope::Team ? 'team' : "{$grant->scope->value}:{$grant->uuid}";
|
||||
if ($grant->levels === [] || ! in_array($target, $validTargets, true)) {
|
||||
throw new \Exception('Choose valid member access.');
|
||||
}
|
||||
}
|
||||
$access = Grant::listToArray($grants);
|
||||
}
|
||||
|
||||
$this->email = strtolower($this->email);
|
||||
|
||||
$member_emails = currentTeam()->members()->get()->pluck('email');
|
||||
@@ -109,13 +144,14 @@ class InviteLink extends Component
|
||||
'role' => $this->role,
|
||||
'link' => $link,
|
||||
'via' => $sendEmail ? 'email' : 'link',
|
||||
]);
|
||||
], ['access' => $access]);
|
||||
auditLog('ui.team_invitation.created', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'invitation_uuid' => $invitation->uuid,
|
||||
'invitation_email' => $invitation->email,
|
||||
'role' => $invitation->role,
|
||||
'via' => $invitation->via,
|
||||
'access' => $invitation->access,
|
||||
]);
|
||||
if ($sendEmail) {
|
||||
$mail = new MailMessage;
|
||||
|
||||
@@ -25,7 +25,11 @@ 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 target is `team`, or `<scope>:<uuid>` for a project, environment, or resource of the team.
|
||||
* 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
|
||||
{
|
||||
@@ -37,9 +41,12 @@ class Access extends Component
|
||||
#[Locked]
|
||||
public ?int $memberId = null;
|
||||
|
||||
#[Locked]
|
||||
public bool $forInvitation = false;
|
||||
|
||||
public bool $open = false;
|
||||
|
||||
/** @var list<array{key: string, target: string, levels: array<string, bool>}> */
|
||||
/** @var list<array{key: string, targets: list<string>, levels: array<string, bool>}> */
|
||||
public array $grants = [];
|
||||
|
||||
public function mount(): void
|
||||
@@ -54,67 +61,59 @@ class Access extends Component
|
||||
{
|
||||
$this->authorize('manageMembers', Team::findOrFail($this->teamId));
|
||||
$this->memberId = $memberId;
|
||||
$this->forInvitation = false;
|
||||
$this->resetValidation();
|
||||
|
||||
$stored = $this->member()->pivot->access;
|
||||
$grants = $stored === null ? AccessResolver::defaultMemberGrants() : Grant::listFrom($stored);
|
||||
$this->grants = array_map(
|
||||
fn (Grant $grant) => $this->row($grant->scope === AccessScope::Team ? 'team' : "{$grant->scope->value}:{$grant->uuid}", $grant->levels),
|
||||
$grants,
|
||||
);
|
||||
$this->grants = $this->rowsFrom($stored === null ? AccessResolver::defaultMemberGrants() : Grant::listFrom($stored));
|
||||
$this->open = true;
|
||||
}
|
||||
|
||||
public function addGrant(): void
|
||||
/**
|
||||
* @param list<array{scope: string, uuid: string|null, levels: list<string>}> $access
|
||||
*/
|
||||
#[On('open-invitation-access')]
|
||||
public function openForInvitation(array $access): void
|
||||
{
|
||||
$this->grants[] = $this->row('', []);
|
||||
}
|
||||
|
||||
public function removeGrant(string $key): void
|
||||
{
|
||||
$this->grants = array_values(array_filter($this->grants, fn (array $row) => $row['key'] !== $key));
|
||||
}
|
||||
|
||||
public function applyPreset(string $preset, ?string $projectUuid = null): void
|
||||
{
|
||||
$this->grants = match ($preset) {
|
||||
'viewer' => [$this->row('team', [AccessLevel::Read])],
|
||||
'developer' => [$this->row("project:{$projectUuid}", [AccessLevel::Write, AccessLevel::Deploy])],
|
||||
'operator' => [$this->row('team', [AccessLevel::Write, AccessLevel::Deploy, AccessLevel::Delete])],
|
||||
'full' => [$this->row('team', [AccessLevel::Root])],
|
||||
default => $this->grants,
|
||||
};
|
||||
$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();
|
||||
|
||||
$validTargets = collect($this->scopeOptions)->reject(fn (array $option) => $option['header'] ?? false)->pluck('value')->all();
|
||||
$grants = [];
|
||||
$errors = [];
|
||||
foreach ($this->grants as $index => $row) {
|
||||
$target = (string) ($row['target'] ?? '');
|
||||
$levels = array_values(array_filter(AccessLevel::cases(), fn (AccessLevel $level) => (bool) ($row['levels'][$level->name] ?? false)));
|
||||
|
||||
if (! in_array($target, $validTargets, true)) {
|
||||
$errors["grants.{$index}.target"] = 'Choose a scope in this team.';
|
||||
}
|
||||
if ($levels === []) {
|
||||
$errors["grants.{$index}.levels"] = 'Choose at least one level.';
|
||||
}
|
||||
if (isset($errors["grants.{$index}.target"]) || isset($errors["grants.{$index}.levels"])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$scope, $uuid] = $target === 'team' ? ['team', null] : explode(':', $target, 2);
|
||||
$grants[] = new Grant(AccessScope::from($scope), $uuid, $levels);
|
||||
}
|
||||
if ($errors !== []) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
$grants = $this->validatedGrants();
|
||||
|
||||
$member->teams()->updateExistingPivot($this->teamId, ['access' => json_encode(Grant::listToArray($grants))]);
|
||||
app(AccessResolver::class)->flush();
|
||||
@@ -132,6 +131,50 @@ class Access extends Component
|
||||
$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.
|
||||
@@ -140,9 +183,17 @@ class Access extends Component
|
||||
*/
|
||||
#[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', $this->teamId)
|
||||
->where('team_id', $teamId)
|
||||
->orderBy('name')
|
||||
->get(['id', 'uuid', 'name']);
|
||||
$environments = Environment::withoutGlobalScope(VisibleThroughAccessGrants::class)
|
||||
@@ -206,16 +257,30 @@ class Access extends Component
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
if ($this->memberId === null) {
|
||||
return view('livewire.team.member.access', ['member' => null]);
|
||||
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]);
|
||||
}
|
||||
$member = $this->member();
|
||||
|
||||
return view('livewire.team.member.access', [
|
||||
'member' => $member,
|
||||
'title' => $title,
|
||||
'team' => Team::findOrFail($this->teamId),
|
||||
'currentSummary' => self::summary($member->pivot->access),
|
||||
'levels' => AccessLevel::cases(),
|
||||
'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(),
|
||||
]);
|
||||
}
|
||||
@@ -229,14 +294,33 @@ class Access extends Component
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<AccessLevel> $levels
|
||||
* @return array{key: string, target: string, levels: array<string, bool>}
|
||||
* 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 row(string $target, array $levels): array
|
||||
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),
|
||||
'target' => $target,
|
||||
'targets' => $targets,
|
||||
'levels' => collect(AccessLevel::cases())->mapWithKeys(fn (AccessLevel $level) => [$level->name => in_array($level, $levels, true)])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ class TeamInvitation extends Model
|
||||
'role',
|
||||
'link',
|
||||
'via',
|
||||
'access',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'link' => 'encrypted',
|
||||
'access' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -27,6 +29,19 @@ class TeamInvitation extends Model
|
||||
$this->attributes['email'] = strtolower($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pivot attributes for the new `team_user` row. Grants apply only to the member role.
|
||||
*
|
||||
* @return array{role: string, access: string|null}
|
||||
*/
|
||||
public function membershipAttributes(): array
|
||||
{
|
||||
return [
|
||||
'role' => $this->role,
|
||||
'access' => $this->role === 'member' && $this->access !== null ? json_encode($this->access) : null,
|
||||
];
|
||||
}
|
||||
|
||||
public function team()
|
||||
{
|
||||
return $this->belongsTo(Team::class);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('team_invitations', 'access')) {
|
||||
Schema::table('team_invitations', function (Blueprint $table) {
|
||||
$table->json('access')->nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('team_invitations', function (Blueprint $table) {
|
||||
$table->dropColumn('access');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -212,10 +212,6 @@
|
||||
: null,
|
||||
['id' => 'preview-deployments-section', 'label' => 'Deployments'],
|
||||
])),
|
||||
'project.application.healthcheck' => [
|
||||
['id' => 'healthcheck-configuration-section', 'label' => 'Configuration'],
|
||||
['id' => 'healthcheck-timing-section', 'label' => 'Timing and retries'],
|
||||
],
|
||||
'project.application.resource-operations' => [
|
||||
['id' => 'clone-resource-section', 'label' => 'Clone resource'],
|
||||
['id' => 'move-resource-section', 'label' => 'Move resource'],
|
||||
|
||||
@@ -92,19 +92,19 @@
|
||||
</div>
|
||||
</x-application.settings-subsection>
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="healthcheck-timing-section" title="Timing and retries"
|
||||
helper="Control how quickly healthchecks start, repeat, and fail.">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number"
|
||||
id="healthCheckInterval" placeholder="30" label="Interval (seconds)" required />
|
||||
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number"
|
||||
id="healthCheckTimeout" placeholder="30" label="Timeout (seconds)" required />
|
||||
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number"
|
||||
id="healthCheckRetries" placeholder="3" label="Retries" required />
|
||||
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number"
|
||||
id="healthCheckStartPeriod" placeholder="30" label="Start period (seconds)" required />
|
||||
</div>
|
||||
<x-application.settings-subsection id="healthcheck-timing-section" title="Timing and retries"
|
||||
helper="Control how quickly healthchecks start, repeat, and fail.">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number"
|
||||
id="healthCheckInterval" placeholder="30" label="Interval (seconds)" required />
|
||||
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number"
|
||||
id="healthCheckTimeout" placeholder="30" label="Timeout (seconds)" required />
|
||||
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number"
|
||||
id="healthCheckRetries" placeholder="3" label="Retries" required />
|
||||
<x-forms.input canGate="update" :canResource="$resource" min="1" type="number"
|
||||
id="healthCheckStartPeriod" placeholder="30" label="Start period (seconds)" required />
|
||||
</div>
|
||||
</x-application.settings-subsection>
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
|
||||
@@ -4,35 +4,38 @@
|
||||
<x-application.settings-section title="Pending invitations"
|
||||
description="Invitation links that have not been accepted yet." flush>
|
||||
<div class="overflow-x-auto">
|
||||
<div class="data-table min-w-[760px] transition-opacity" wire:loading.class="opacity-50 pointer-events-none"
|
||||
<div class="data-table min-w-[600px] transition-opacity" wire:loading.class="opacity-50 pointer-events-none"
|
||||
wire:target="setSort">
|
||||
<div
|
||||
class="data-table-header grid-cols-[minmax(13rem,1.2fr)_7rem_7rem_minmax(15rem,1.5fr)_4.5rem]">
|
||||
class="data-table-header grid-cols-[minmax(13rem,1fr)_7rem_10rem_4rem_4.5rem]">
|
||||
<x-table.sort-header label="Email" asc="email-asc" desc="email-desc" action="setSort"
|
||||
:current="$sort" />
|
||||
<x-table.sort-header label="Method" asc="via-asc" desc="via-desc" action="setSort"
|
||||
:current="$sort" />
|
||||
<x-table.sort-header label="Role" asc="role-asc" desc="role-desc" first="desc"
|
||||
action="setSort" :current="$sort" />
|
||||
<span>Invitation link</span>
|
||||
<span>Link</span>
|
||||
<span class="text-right">Actions</span>
|
||||
</div>
|
||||
@foreach ($sortedInvitations as $invite)
|
||||
<div wire:key="team-invitation-{{ $invite->id }}"
|
||||
class="data-table-row grid-cols-[minmax(13rem,1.2fr)_7rem_7rem_minmax(15rem,1.5fr)_4.5rem] border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
class="data-table-row grid-cols-[minmax(13rem,1fr)_7rem_10rem_4rem_4.5rem] border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
|
||||
<div class="truncate text-[12px] font-medium text-black dark:text-fg">
|
||||
{{ $invite->email }}
|
||||
</div>
|
||||
<div class="text-[12px] capitalize text-neutral-500 dark:text-fg-dim">
|
||||
{{ $invite->via }}
|
||||
</div>
|
||||
<div class="text-[12px] capitalize text-neutral-500 dark:text-fg-dim">
|
||||
{{ $invite->role }}
|
||||
<div class="min-w-0 text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
<div class="capitalize">{{ $invite->role }}</div>
|
||||
@if ($invite->role === 'member')
|
||||
@php $accessSummary = \App\Livewire\Team\Member\Access::summary($invite->access); @endphp
|
||||
<div class="truncate text-[11px] text-neutral-400 dark:text-fg-faint" title="{{ $accessSummary }}">
|
||||
{{ $accessSummary }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
class="min-w-0 truncate font-mono text-[12px] text-neutral-500 dark:text-fg-dim"
|
||||
title="{{ $invite->link }}">{{ $invite->link }}</span>
|
||||
<div class="flex items-center">
|
||||
<x-copy-button :value="$invite->link" label="Copy invitation link" />
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
|
||||
@@ -26,11 +26,26 @@
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input id="email" type="email" label="Email address"
|
||||
placeholder="teammate@example.com" required />
|
||||
<x-forms.listbox id="role" label="Role" :options="array_values(array_filter([
|
||||
auth()->user()->role() === 'owner' ? ['value' => 'owner', 'label' => 'Owner'] : null,
|
||||
['value' => 'admin', 'label' => 'Admin'],
|
||||
['value' => 'member', 'label' => 'Member'],
|
||||
]))" />
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<x-forms.listbox id="role" label="Role" :options="array_values(array_filter([
|
||||
auth()->user()->role() === 'owner' ? ['value' => 'owner', 'label' => 'Owner'] : null,
|
||||
['value' => 'admin', 'label' => 'Admin'],
|
||||
['value' => 'member', 'label' => 'Member'],
|
||||
]))" />
|
||||
</div>
|
||||
<div x-show="$wire.role === 'member'" class="shrink-0">
|
||||
<x-forms.button type="button" defaultClass="button button-highlighted"
|
||||
title="{{ \App\Livewire\Team\Member\Access::summary($grants) }}"
|
||||
wire:click="$dispatch('open-invitation-access', { access: {{ Js::from($grants) }} })">
|
||||
<x-reicon name="keys" class="size-3.5" />
|
||||
Scopes
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
<p x-show="$wire.role === 'member'" class="text-[12px] text-neutral-500 lg:col-span-2 dark:text-fg-faint">
|
||||
Scopes: <span class="text-neutral-700 dark:text-fg-dim">{{ \App\Livewire\Team\Member\Access::summary($grants) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</form>
|
||||
|
||||
@@ -1,83 +1,184 @@
|
||||
<div>
|
||||
@if ($member)
|
||||
<x-modal-input title="Access for {{ $member->name }}" :subtitle="'Now: '.$currentSummary" wireOpen="open"
|
||||
@if ($title)
|
||||
<x-modal-input :title="$title" :subtitle="$currentSummary ? 'Current access: '.$currentSummary : 'The member gets these scopes when they accept the invitation.'" wireOpen="open"
|
||||
:wireIgnore="false" :closeOutside="false">
|
||||
<x-slot:content><span></span></x-slot:content>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-[12px] text-neutral-500 dark:text-fg-dim">Presets</span>
|
||||
<button type="button" class="button h-7! px-2.5! text-[11px]!" wire:click="applyPreset('viewer')">
|
||||
Viewer
|
||||
</button>
|
||||
@if ($projectOptions->isNotEmpty())
|
||||
<div x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<button type="button" class="button h-7! px-2.5! text-[11px]!" x-ref="trigger"
|
||||
@click="open = !open" aria-haspopup="menu" :aria-expanded="open">
|
||||
Developer
|
||||
<x-reicon name="chevron-down" class="size-3" />
|
||||
</button>
|
||||
<template x-teleport="body">
|
||||
<div x-show="open" x-cloak role="menu" x-anchor.bottom-start.fixed.offset.4="$refs.trigger"
|
||||
@click.outside="if (! $refs.trigger.contains($event.target)) open = false"
|
||||
class="listbox-panel min-w-48! z-[100]!">
|
||||
@foreach ($projectOptions as $project)
|
||||
<button type="button" class="listbox-option justify-start!"
|
||||
wire:click="applyPreset('developer', '{{ Str::after($project['value'], 'project:') }}')"
|
||||
@click="open = false">
|
||||
{{ $project['label'] }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
<button type="button" class="button h-7! px-2.5! text-[11px]!" wire:click="applyPreset('operator')">
|
||||
Operator
|
||||
</button>
|
||||
<button type="button" class="button h-7! px-2.5! text-[11px]!" wire:click="applyPreset('full')">
|
||||
Full access
|
||||
</button>
|
||||
</div>
|
||||
@php
|
||||
$presetInfo = [
|
||||
'viewer' => ['label' => 'Viewer', 'description' => 'Read the whole team.'],
|
||||
'developer' => ['label' => 'Developer', 'description' => 'Write and deploy in one project.'],
|
||||
'operator' => ['label' => 'Operator', 'description' => 'Write, deploy, and delete in the whole team.'],
|
||||
'full' => ['label' => 'Full access', 'description' => 'Every level in the whole team.'],
|
||||
];
|
||||
$presetCard = 'flex w-full flex-col items-start gap-0.5 rounded-lg border px-3 py-2 text-left transition-colors';
|
||||
$presetIdle = 'border-neutral-200 hover:bg-neutral-100/80 dark:border-white/[0.08] dark:hover:bg-white/[0.035]';
|
||||
$presetActive = 'border-coollabs bg-coollabs/5 dark:border-brand-light/60 dark:bg-brand/10';
|
||||
@endphp
|
||||
|
||||
@if (count($grants) === 0)
|
||||
<x-callout type="warning" title="No access">
|
||||
This member will not see any project, environment, or resource in this team.
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
@foreach ($grants as $index => $grant)
|
||||
<div wire:key="member-access-grant-{{ $grant['key'] }}"
|
||||
class="flex flex-col gap-2 border-b border-neutral-200 pb-4 last:border-b-0 last:pb-0 dark:border-white/[0.08]">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<x-forms.listbox id="grants.{{ $index }}.target" :options="$this->scopeOptions"
|
||||
placeholder="Choose a scope" searchable portal canGate="manageMembers"
|
||||
:canResource="$team" />
|
||||
</div>
|
||||
<button type="button" class="icon-button mt-1 shrink-0" title="Remove"
|
||||
aria-label="Remove this grant" wire:click="removeGrant('{{ $grant['key'] }}')">
|
||||
<x-reicon name="trash" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-1">
|
||||
@foreach ($levels as $level)
|
||||
<x-forms.checkbox id="grants.{{ $index }}.levels.{{ $level->name }}"
|
||||
:label="$level->label()" canGate="manageMembers" :canResource="$team" />
|
||||
@endforeach
|
||||
</div>
|
||||
@error("grants.{$index}.levels")
|
||||
<p class="text-[12px] text-error">{{ $message }}</p>
|
||||
@enderror
|
||||
{{-- The rows live in Alpine. They reach the server only with Save, because the entangle is not live. --}}
|
||||
<div class="flex flex-col gap-5 lg:w-3xl" x-data="{
|
||||
rows: $wire.entangle('grants'),
|
||||
levels: @js($levels),
|
||||
presets: @js($presets),
|
||||
row(targets, levels) {
|
||||
return {
|
||||
key: Math.random().toString(36).slice(2, 12),
|
||||
targets,
|
||||
levels: Object.fromEntries(this.levels.map((level) => [level.name, levels.includes(level.name)])),
|
||||
};
|
||||
},
|
||||
checked(row) {
|
||||
return this.levels.filter((level) => row.levels[level.name]).map((level) => level.name).sort();
|
||||
},
|
||||
applyPreset(name, projectUuid = null) {
|
||||
const preset = this.presets[name];
|
||||
const targets = preset.targets.map((target) => target.replace('__project__', projectUuid));
|
||||
this.rows = [this.row(targets, this.checked(preset))];
|
||||
},
|
||||
addRow() {
|
||||
this.rows = [...this.rows, this.row([], [])];
|
||||
},
|
||||
removeRow(key) {
|
||||
this.rows = this.rows.filter((row) => row.key !== key);
|
||||
},
|
||||
includedBy(row, name) {
|
||||
const parent = this.levels
|
||||
.filter((level) => row.levels[level.name])
|
||||
.sort((a, b) => b.includes.length - a.includes.length)
|
||||
.find((level) => level.name !== name && level.includes.includes(name));
|
||||
return parent?.label ?? null;
|
||||
},
|
||||
get activePreset() {
|
||||
if (this.rows.length !== 1 || this.rows[0].targets.length !== 1) return null;
|
||||
const target = String(this.rows[0].targets[0]);
|
||||
const levels = this.checked(this.rows[0]).join(',');
|
||||
return Object.keys(this.presets).find((name) => {
|
||||
const preset = this.presets[name];
|
||||
const presetTarget = preset.targets[0];
|
||||
const sameTarget = presetTarget.startsWith('project:') ? target.startsWith('project:') : target === presetTarget;
|
||||
return sameTarget && this.checked(preset).join(',') === levels;
|
||||
}) ?? null;
|
||||
},
|
||||
}">
|
||||
<section class="flex flex-col gap-2">
|
||||
<div>
|
||||
<h4 class="text-[13px] font-medium text-neutral-800 dark:text-fg">Start from a preset</h4>
|
||||
<p class="text-[12px] text-neutral-500 dark:text-fg-faint">A preset replaces the scopes below.
|
||||
You can change them after.</p>
|
||||
</div>
|
||||
@endforeach
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
@foreach ($presetInfo as $preset => $info)
|
||||
@if ($preset === 'developer')
|
||||
@if ($projectOptions->isNotEmpty())
|
||||
<div x-data="{ open: false }" @keydown.escape.window="open = false">
|
||||
<button type="button" class="{{ $presetCard }}" x-ref="trigger"
|
||||
:class="activePreset === 'developer' ? @js($presetActive) : @js($presetIdle)"
|
||||
@click="open = !open" aria-haspopup="menu" :aria-expanded="open">
|
||||
<span class="flex w-full items-center justify-between gap-2 text-[13px] text-neutral-800 dark:text-fg">
|
||||
{{ $info['label'] }}
|
||||
<x-reicon name="chevron-down" class="size-3 text-neutral-500 dark:text-fg-faint" />
|
||||
</span>
|
||||
<span class="text-[12px] text-neutral-500 dark:text-fg-faint">{{ $info['description'] }}</span>
|
||||
</button>
|
||||
<template x-teleport="body">
|
||||
<div x-show="open" x-cloak role="menu"
|
||||
x-anchor.bottom-start.fixed.offset.4="$refs.trigger"
|
||||
@click.outside="if (! $refs.trigger.contains($event.target)) open = false"
|
||||
class="listbox-panel min-w-48! z-[100]!">
|
||||
@foreach ($projectOptions as $project)
|
||||
<button type="button" class="listbox-option justify-start!"
|
||||
@click="applyPreset('developer', @js(Str::after($project['value'], 'project:'))); open = false">
|
||||
{{ $project['label'] }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<button type="button" class="{{ $presetCard }}"
|
||||
:class="activePreset === @js($preset) ? @js($presetActive) : @js($presetIdle)"
|
||||
@click="applyPreset(@js($preset))">
|
||||
<span class="text-[13px] text-neutral-800 dark:text-fg">{{ $info['label'] }}</span>
|
||||
<span class="text-[12px] text-neutral-500 dark:text-fg-faint">{{ $info['description'] }}</span>
|
||||
</button>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<button type="button" class="button h-7! px-2.5! text-[11px]!" wire:click="addGrant">
|
||||
<x-reicon name="plus" class="size-3" />
|
||||
Add grant
|
||||
</button>
|
||||
</div>
|
||||
<section class="flex flex-col gap-2">
|
||||
<div>
|
||||
<h4 class="text-[13px] font-medium text-neutral-800 dark:text-fg">Scopes</h4>
|
||||
<p class="text-[12px] text-neutral-500 dark:text-fg-faint">Each scope gives levels on the whole
|
||||
team, projects, environments, or resources. Scopes add up.</p>
|
||||
</div>
|
||||
|
||||
<div x-show="rows.length === 0" x-cloak>
|
||||
<x-callout type="warning" title="No access">
|
||||
{{ $forInvitation ? 'The invited member' : 'This member' }} will not see any project, environment, or resource in this team.
|
||||
</x-callout>
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
<x-callout type="danger" title="Check the scopes">
|
||||
@foreach ($errors->getMessages() as $key => $messages)
|
||||
<p>Scope {{ ((int) explode('.', $key)[1]) + 1 }}: {{ $messages[0] }}</p>
|
||||
@endforeach
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
<template x-for="row in rows" :key="row.key">
|
||||
<div class="flex flex-col gap-3 rounded-lg border border-neutral-200 p-3 dark:border-white/[0.08]">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<x-forms.listbox htmlId="member-access-targets" label="Applies to" :wire="false" :value="[]"
|
||||
x-model="row.targets" :options="$this->scopeOptions" placeholder="Choose scopes"
|
||||
multiple searchable portal countLabel="resource" />
|
||||
</div>
|
||||
<button type="button" class="icon-button mb-1 shrink-0" title="Remove scope"
|
||||
aria-label="Remove this scope" @click="removeRow(row.key)">
|
||||
<x-reicon name="trash" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
<template x-for="level in levels" :key="level.name">
|
||||
<label class="group flex items-start gap-2.5 rounded-md px-2.5 py-2 transition-colors"
|
||||
:class="includedBy(row, level.name) ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-neutral-100/80 dark:hover:bg-white/[0.035]'">
|
||||
<span class="relative mt-0.5 flex size-[18px] shrink-0">
|
||||
<input type="checkbox"
|
||||
:checked="includedBy(row, level.name) !== null || row.levels[level.name]"
|
||||
@change="row.levels[level.name] = $event.target.checked"
|
||||
:disabled="includedBy(row, level.name) !== null"
|
||||
class="peer absolute inset-0 z-10 m-0 h-full w-full cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed" />
|
||||
<span
|
||||
class="pointer-events-none absolute inset-0 rounded-[5px] border border-neutral-300 bg-white transition-colors peer-checked:border-coollabs peer-checked:bg-coollabs peer-focus-visible:ring-2 peer-focus-visible:ring-coollabs/25 dark:border-white/[0.14] dark:bg-white/[0.045] dark:peer-checked:border-brand dark:peer-checked:bg-brand"></span>
|
||||
<svg class="pointer-events-none absolute inset-0 m-auto size-3 text-white opacity-0 peer-checked:opacity-100"
|
||||
viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-col">
|
||||
<span class="text-[13px] text-neutral-800 dark:text-fg" x-text="level.label"></span>
|
||||
<span class="text-[12px] text-neutral-500 dark:text-fg-faint"
|
||||
x-text="includedBy(row, level.name) ? `Included in ${includedBy(row, level.name)}.` : level.description"></span>
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<button type="button" class="button h-7! px-2.5! text-[11px]!" @click="addRow()">
|
||||
<x-reicon name="plus" class="size-3" />
|
||||
Add scope
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<x-slot:footer>
|
||||
|
||||
@@ -81,10 +81,10 @@ it('lets an owner save grants for a member through the access editor', function
|
||||
memberGrantsActAs($this->owner, $this->team);
|
||||
|
||||
Livewire::test(Access::class)->call('openFor', $member->id)
|
||||
->call('applyPreset', 'developer', $this->shop->uuid)
|
||||
->call('addGrant')
|
||||
->set('grants.1.target', 'resource:'.$this->blogWeb->uuid)
|
||||
->set('grants.1.levels.Read', true)
|
||||
->set('grants', [
|
||||
['key' => 'a', 'targets' => ['project:'.$this->shop->uuid], 'levels' => ['Write' => true, 'Deploy' => true]],
|
||||
['key' => 'b', 'targets' => ['resource:'.$this->blogWeb->uuid], 'levels' => ['Read' => true]],
|
||||
])
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
@@ -94,12 +94,31 @@ it('lets an owner save grants for a member through the access editor', function
|
||||
]);
|
||||
});
|
||||
|
||||
it('saves one grant for each target of a scope and groups them again on open', function () {
|
||||
$member = memberGrantsUser($this->team, 'member', null);
|
||||
memberGrantsActAs($this->owner, $this->team);
|
||||
|
||||
Livewire::test(Access::class)->call('openFor', $member->id)
|
||||
->set('grants.0.targets', ['project:'.$this->shop->uuid, 'resource:'.$this->blogWeb->uuid])
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(memberGrantsStoredAccess($this->team, $member))->toBe([
|
||||
['scope' => 'project', 'uuid' => $this->shop->uuid, 'levels' => ['read']],
|
||||
['scope' => 'resource', 'uuid' => $this->blogWeb->uuid, 'levels' => ['read']],
|
||||
]);
|
||||
|
||||
Livewire::test(Access::class)->call('openFor', $member->id)
|
||||
->assertCount('grants', 1)
|
||||
->assertSet('grants.0.targets', ['project:'.$this->shop->uuid, 'resource:'.$this->blogWeb->uuid]);
|
||||
});
|
||||
|
||||
it('stores an empty list when the owner removes every grant', function () {
|
||||
$member = memberGrantsUser($this->team, 'member', null);
|
||||
memberGrantsActAs($this->owner, $this->team);
|
||||
|
||||
$editor = Livewire::test(Access::class)->call('openFor', $member->id);
|
||||
$editor->call('removeGrant', $editor->get('grants.0.key'))
|
||||
Livewire::test(Access::class)->call('openFor', $member->id)
|
||||
->set('grants', [])
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
@@ -118,7 +137,7 @@ it('does not let a member open the editor or save grants', function () {
|
||||
memberGrantsActAs($this->owner, $this->team);
|
||||
$editor = Livewire::test(Access::class)
|
||||
->call('openFor', $target->id)
|
||||
->call('applyPreset', 'full');
|
||||
->set('grants', [['key' => 'a', 'targets' => ['team'], 'levels' => ['Root' => true]]]);
|
||||
|
||||
memberGrantsActAs($member->fresh(), $this->team);
|
||||
$editor->call('save')->assertForbidden();
|
||||
@@ -133,9 +152,9 @@ it('rejects a scope from another team', function () {
|
||||
memberGrantsActAs($this->owner, $this->team);
|
||||
|
||||
Livewire::test(Access::class)->call('openFor', $member->id)
|
||||
->set('grants.0.target', 'project:'.$foreignProject->uuid)
|
||||
->set('grants.0.targets', ['team', 'project:'.$foreignProject->uuid])
|
||||
->call('save')
|
||||
->assertHasErrors(['grants.0.target']);
|
||||
->assertHasErrors(['grants.0.targets']);
|
||||
|
||||
expect(memberGrantsStoredAccess($this->team, $member))->toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user