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:
parent
2d07002325
commit
6c47bac1b8
10 changed files with 88 additions and 31 deletions
|
|
@ -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],
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'))],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -225,8 +225,7 @@
|
|||
|
||||
'cancel' => 'Cancel',
|
||||
'remove' => 'Remove',
|
||||
'make_admin' => 'Make admin',
|
||||
'make_member' => 'Make member',
|
||||
'make_role' => 'Make :role',
|
||||
|
||||
'invite' => [
|
||||
'title' => 'Invite Member',
|
||||
|
|
|
|||
|
|
@ -225,8 +225,7 @@
|
|||
|
||||
'cancel' => 'Cancelar',
|
||||
'remove' => 'Eliminar',
|
||||
'make_admin' => 'Hacer administrador',
|
||||
'make_member' => 'Hacer miembro',
|
||||
'make_role' => 'Hacer :role',
|
||||
|
||||
'invite' => [
|
||||
'title' => 'Invitar miembro',
|
||||
|
|
|
|||
|
|
@ -225,8 +225,7 @@
|
|||
|
||||
'cancel' => 'Cancelar',
|
||||
'remove' => 'Remover',
|
||||
'make_admin' => 'Tornar administrador',
|
||||
'make_member' => 'Tornar membro',
|
||||
'make_role' => 'Tornar :role',
|
||||
|
||||
'invite' => [
|
||||
'title' => 'Convidar Membro',
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue