diff --git a/app/Enums/AccessLevel.php b/app/Enums/AccessLevel.php index 6ec41a3506..b5286a9595 100644 --- a/app/Enums/AccessLevel.php +++ b/app/Enums/AccessLevel.php @@ -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. * diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 52a0868c92..175eb30e91 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -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); diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index 08c913ee2e..ad5434f4b7 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -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}> + */ + 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}> $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; diff --git a/app/Livewire/Team/Member/Access.php b/app/Livewire/Team/Member/Access.php index 7c085fe1d3..a52fac3910 100644 --- a/app/Livewire/Team/Member/Access.php +++ b/app/Livewire/Team/Member/Access.php @@ -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 `:` 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 `:` + * 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}> */ + /** @var list, levels: array}> */ 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}> $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|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 `:`) that exist in a team. + * + * @return list + */ + public static function validTargets(int $teamId): array + { + return collect(self::scopeOptionsFor($teamId))->reject(fn (array $option) => $option['header'] ?? false)->pluck('value')->all(); + } + + /** + * @return list + */ + 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 + */ + 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 $levels - * @return array{key: string, target: string, levels: array} + * Editor rows for stored grants. Grants with the same levels share one row. + * + * @param list $grants + * @return list, levels: array}> */ - 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 $targets + * @param list $levels + * @return array{key: string, targets: list, levels: array} + */ + 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(), ]; } diff --git a/app/Models/TeamInvitation.php b/app/Models/TeamInvitation.php index f2a906e915..b6dac4cb31 100644 --- a/app/Models/TeamInvitation.php +++ b/app/Models/TeamInvitation.php @@ -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); diff --git a/database/migrations/2026_10_30_144551_add_access_to_team_invitations_table.php b/database/migrations/2026_10_30_144551_add_access_to_team_invitations_table.php new file mode 100644 index 0000000000..33274cb9a0 --- /dev/null +++ b/database/migrations/2026_10_30_144551_add_access_to_team_invitations_table.php @@ -0,0 +1,24 @@ +json('access')->nullable(); + }); + } + } + + public function down(): void + { + Schema::table('team_invitations', function (Blueprint $table) { + $table->dropColumn('access'); + }); + } +}; diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index 592c81e48f..1621c1ed33 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -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'], diff --git a/resources/views/livewire/project/shared/health-checks.blade.php b/resources/views/livewire/project/shared/health-checks.blade.php index 0a0cba98d1..d298963454 100644 --- a/resources/views/livewire/project/shared/health-checks.blade.php +++ b/resources/views/livewire/project/shared/health-checks.blade.php @@ -92,19 +92,19 @@ @endif - - -
- - - - -
+ +
+ + + + +
+
diff --git a/resources/views/livewire/team/invitations.blade.php b/resources/views/livewire/team/invitations.blade.php index f8f885b03b..0fcb6d6837 100644 --- a/resources/views/livewire/team/invitations.blade.php +++ b/resources/views/livewire/team/invitations.blade.php @@ -4,35 +4,38 @@
-
+ class="data-table-header grid-cols-[minmax(13rem,1fr)_7rem_10rem_4rem_4.5rem]"> - Invitation link + Link Actions
@foreach ($sortedInvitations as $invite)
+ 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]">
{{ $invite->email }}
{{ $invite->via }}
-
- {{ $invite->role }} +
+
{{ $invite->role }}
+ @if ($invite->role === 'member') + @php $accessSummary = \App\Livewire\Team\Member\Access::summary($invite->access); @endphp +
+ {{ $accessSummary }} +
+ @endif
-
- {{ $invite->link }} +
diff --git a/resources/views/livewire/team/invite-link.blade.php b/resources/views/livewire/team/invite-link.blade.php index fb8426cbdb..f20f126925 100644 --- a/resources/views/livewire/team/invite-link.blade.php +++ b/resources/views/livewire/team/invite-link.blade.php @@ -26,11 +26,26 @@
- +
+
+ +
+
+ + + Scopes + +
+
+

+ Scopes: {{ \App\Livewire\Team\Member\Access::summary($grants) }} +

diff --git a/resources/views/livewire/team/member/access.blade.php b/resources/views/livewire/team/member/access.blade.php index 7256d17145..8f2bda6f4c 100644 --- a/resources/views/livewire/team/member/access.blade.php +++ b/resources/views/livewire/team/member/access.blade.php @@ -1,83 +1,184 @@
- @if ($member) - -
-
- Presets - - @if ($projectOptions->isNotEmpty()) -
- - -
- @endif - - -
+ @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) - - This member will not see any project, environment, or resource in this team. - - @endif - - @foreach ($grants as $index => $grant) -
-
-
- -
- -
-
- @foreach ($levels as $level) - - @endforeach -
- @error("grants.{$index}.levels") -

{{ $message }}

- @enderror + {{-- The rows live in Alpine. They reach the server only with Save, because the entangle is not live. --}} +
+
+
+

Start from a preset

+

A preset replaces the scopes below. + You can change them after.

- @endforeach +
+ @foreach ($presetInfo as $preset => $info) + @if ($preset === 'developer') + @if ($projectOptions->isNotEmpty()) +
+ + +
+ @endif + @else + + @endif + @endforeach +
+
-
- -
+
+
+

Scopes

+

Each scope gives levels on the whole + team, projects, environments, or resources. Scopes add up.

+
+ +
+ + {{ $forInvitation ? 'The invited member' : 'This member' }} will not see any project, environment, or resource in this team. + +
+ + @if ($errors->any()) + + @foreach ($errors->getMessages() as $key => $messages) +

Scope {{ ((int) explode('.', $key)[1]) + 1 }}: {{ $messages[0] }}

+ @endforeach +
+ @endif + + + +
+ +
+
diff --git a/tests/Feature/Access/MemberGrantsTest.php b/tests/Feature/Access/MemberGrantsTest.php index a895283f78..8ab049be17 100644 --- a/tests/Feature/Access/MemberGrantsTest.php +++ b/tests/Feature/Access/MemberGrantsTest.php @@ -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(); });