feat(access): set member grants when inviting

This commit is contained in:
Andras Bacsai
2026-10-30 16:41:30 +01:00
parent 8fcff6ebd1
commit 091654ce7f
12 changed files with 482 additions and 177 deletions
+12
View File
@@ -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.
*
+2 -2
View File
@@ -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);
+37 -1
View File
@@ -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;
+144 -60
View File
@@ -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(),
];
}
+15
View File
@@ -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>
+28 -9
View File
@@ -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();
});