fix(members): preserve invited role on accept and surface viewer in role menu

AcceptInviteController attached invited users with a hardcoded member role,
ignoring the invite's role entirely (a viewer invite joined as member). Use the
invite's role on accept, require role on invite creation (no silent default),
and drop the role from the request/CreateInvite defaults. Add the Viewer option
to the member role dropdown (now iterates all roles), hide the dropdown on the
current user's own row, and require the member's email to confirm removal.
Covered by tests asserting the accepted role matches the invited role for
viewer/admin/member, plus invite role validation.
This commit is contained in:
Paulo Castellano 2026-06-22 14:24:19 -03:00
parent 2d07002325
commit 6c47bac1b8
10 changed files with 88 additions and 31 deletions

View file

@ -14,14 +14,11 @@ class CreateInvite
{
public static function execute(Workspace $workspace, array $data): Invite
{
$role = WorkspaceRole::tryFrom((string) data_get($data, 'role', WorkspaceRole::Member->value))
?? WorkspaceRole::Member;
$invite = Invite::create([
'account_id' => $workspace->account_id,
'invited_by' => auth()->id(),
'email' => data_get($data, 'email'),
'role' => $role,
'role' => WorkspaceRole::from((string) data_get($data, 'role')),
'workspaces' => [$workspace->id],
]);

View file

@ -4,7 +4,6 @@
namespace App\Http\Controllers\Auth;
use App\Enums\UserWorkspace\Role;
use App\Http\Controllers\Controller;
use App\Models\Invite;
use App\Models\Workspace;
@ -25,7 +24,7 @@ public function show(Invite $invite): Response
$firstWorkspaceId = collect($invite->workspaces ?? [])->first();
$workspace = $firstWorkspaceId ? Workspace::find($firstWorkspaceId) : null;
$role = $invite->role ?? Role::Member;
$role = $invite->role;
return Inertia::render('auth/AcceptInvite', [
'invite' => [
@ -82,7 +81,7 @@ public function accept(Request $request, Invite $invite): RedirectResponse
if ($workspace && $workspace->account_id === $invite->account_id) {
$workspace->members()->syncWithoutDetaching([
$user->id => ['role' => Role::Member->value],
$user->id => ['role' => $invite->role->value],
]);
// Set first workspace as current

View file

@ -28,7 +28,7 @@ public function rules(): array
{
return [
'email' => ['required', 'email', 'max:255'],
'role' => ['nullable', Rule::in(array_column(WorkspaceRole::cases(), 'value'))],
'role' => ['required', Rule::in(array_column(WorkspaceRole::cases(), 'value'))],
];
}

View file

@ -225,8 +225,7 @@
'cancel' => 'Cancel',
'remove' => 'Remove',
'make_admin' => 'Make admin',
'make_member' => 'Make member',
'make_role' => 'Make :role',
'invite' => [
'title' => 'Invite Member',

View file

@ -225,8 +225,7 @@
'cancel' => 'Cancelar',
'remove' => 'Eliminar',
'make_admin' => 'Hacer administrador',
'make_member' => 'Hacer miembro',
'make_role' => 'Hacer :role',
'invite' => [
'title' => 'Invitar miembro',

View file

@ -225,8 +225,7 @@
'cancel' => 'Cancelar',
'remove' => 'Remover',
'make_admin' => 'Tornar administrador',
'make_member' => 'Tornar membro',
'make_role' => 'Tornar :role',
'invite' => [
'title' => 'Convidar Membro',

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconClock, IconDots, IconShield, IconTrash, IconUser } from '@tabler/icons-vue';
import { ref } from 'vue';
import { router, usePage } from '@inertiajs/vue3';
import { IconClock, IconDots, IconEye, IconShield, IconTrash, IconUser } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import HeadingSmall from '@/components/HeadingSmall.vue';
@ -39,11 +39,32 @@ interface Invitation {
role: string;
}
interface Role {
value: string;
label: string;
}
defineProps<{
members: Member[];
invitations: Invitation[];
roles: Role[];
}>();
const roleIcon = (role: string) => {
if (role === WorkspaceRole.Admin) {
return IconShield;
}
if (role === WorkspaceRole.Viewer) {
return IconEye;
}
return IconUser;
};
const page = usePage();
const currentUserId = computed(() => page.props.auth.user.id);
const inviteDialogOpen = ref(false);
const removeMemberModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const cancelInvitationModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
@ -89,7 +110,7 @@ const changeRole = (member: Member, role: string) => {
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenu v-if="member.id !== currentUserId">
<DropdownMenuTrigger as-child>
<Button variant="outline" size="icon" class="size-8">
<IconDots class="size-4" />
@ -97,22 +118,16 @@ const changeRole = (member: Member, role: string) => {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-if="member.role === WorkspaceRole.Member"
@click="changeRole(member, WorkspaceRole.Admin)"
v-for="role in roles.filter((r) => r.value !== member.role)"
:key="role.value"
@click="changeRole(member, role.value)"
>
<IconShield class="size-4" />
{{ $t('settings.members.make_admin') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="member.role === WorkspaceRole.Admin"
@click="changeRole(member, WorkspaceRole.Member)"
>
<IconUser class="size-4" />
{{ $t('settings.members.make_member') }}
<component :is="roleIcon(role.value)" class="size-4" />
{{ $t('settings.members.make_role', { role: $t(`settings.members.roles.${role.value}`) }) }}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
@click="removeMemberModal?.open({ url: removeMemberRoute.url(member.id) })"
@click="removeMemberModal?.open({ url: removeMemberRoute.url(member.id), confirmText: member.email })"
>
<IconTrash class="size-4" />
{{ $t('settings.members.remove') }}

View file

@ -62,7 +62,7 @@ const tabs = computed(() => [
<SettingsTabsNav :tabs="tabs" active="members" />
<UsersTab :members="members" :invitations="invites" />
<UsersTab :members="members" :invitations="invites" :roles="roles" />
</div>
</AppLayout>
</template>

View file

@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\Account;
use App\Models\Invite;
use App\Models\User;
@ -130,6 +131,33 @@
expect($invite->accepted_at)->not->toBeNull();
});
test('accept invite assigns the exact role from the invite', function (Role $role) {
$user = User::factory()->create([
'email' => 'invitee@example.com',
]);
$invite = Invite::factory()->create([
'account_id' => $this->account->id,
'invited_by' => $this->owner->id,
'email' => 'invitee@example.com',
'workspaces' => [$this->workspace->id],
'role' => $role,
]);
$this->actingAs($user)
->post(route('app.invites.accept', $invite))
->assertRedirect(route('app.calendar'));
$member = $this->workspace->members()->where('user_id', $user->id)->first();
expect($member)->not->toBeNull();
expect($member->pivot->role)->toBe($role->value);
})->with([
'viewer' => Role::Viewer,
'admin' => Role::Admin,
'member' => Role::Member,
]);
test('accept invite fails for wrong email', function () {
$user = User::factory()->create([
'email' => 'different@example.com',

View file

@ -80,6 +80,27 @@
Mail::assertQueued(WorkspaceInviteMail::class);
});
test('store invite requires a role', function () {
$response = $this->actingAs($this->user)->post(route('app.invites.store'), [
'email' => 'newmember@example.com',
]);
$response->assertSessionHasErrors('role');
$this->assertDatabaseMissing('invites', ['email' => 'newmember@example.com']);
});
test('store invite persists the chosen role', function () {
$this->actingAs($this->user)->post(route('app.invites.store'), [
'email' => 'viewer@example.com',
'role' => WorkspaceRole::Viewer->value,
]);
$this->assertDatabaseHas('invites', [
'email' => 'viewer@example.com',
'role' => WorkspaceRole::Viewer->value,
]);
});
test('store invite fails if invite already exists', function () {
Invite::factory()->create([
'account_id' => $this->account->id,