diff --git a/CHANGELOG.md b/CHANGELOG.md index ec35800..77856c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [1.1.0] - 2026-06-13 + +### Added +- Invitation accept and decline API endpoints, so an invited user can join (or turn down) a workspace through the same path that issues the invite +- `MemberInvited` and `MemberJoined` events are now dispatched (on invite and on accept), letting host apps react -- for example to send an invitation email + ## [1.0.3] - 2026-06-11 ### Fixed diff --git a/README.md b/README.md index 113e218..0a3d49b 100644 --- a/README.md +++ b/README.md @@ -105,16 +105,37 @@ Define the roles available in a workspace. ## Available Endpoints - -* `GET /api/workspaces/{workspaceId}` - Get a workspace -* `PUT /api/workspaces/{workspaceId}` - Update a workspace -* `GET /api/workspaces/{workspaceId}/members` - Get workspace members -* `POST /api/workspaces/{workspaceId}/members/invite` - Invite a member to a workspace -* `DELETE /api/workspaces/{workspaceId}/members/{userId}` - Remove a member from a workspace +The `{workspace}` parameter is resolved by slug. + +### Workspaces +* `GET /api/workspaces` - List the authenticated user's workspaces +* `POST /api/workspaces` - Create a workspace +* `GET /api/workspaces/{workspace}` - Get a workspace +* `PUT /api/workspaces/{workspace}` - Update a workspace +* `DELETE /api/workspaces/{workspace}` - Delete a workspace +* `POST /api/workspaces/{workspace}/switch` - Switch to a workspace + +### Members +* `GET /api/workspaces/{workspace}/members` - Get workspace members +* `DELETE /api/workspaces/{workspace}/members/{userId}` - Remove a member from a workspace +* `POST /api/workspaces/{workspace}/leave` - Leave a workspace + +### Invitations +* `POST /api/workspaces/{workspace}/members/invite` - Invite a member to a workspace +* `GET /api/workspaces/{workspace}/invitations` - List pending invitations +* `DELETE /api/workspaces/{workspace}/invitations/{invitation}` - Cancel a pending invitation +* `POST /api/workspaces/invitations/{token}/accept` - Accept an invitation +* `POST /api/workspaces/invitations/{token}/decline` - Decline an invitation + +Accept and decline are keyed on the invitation token (the value carried in the invite). The authenticated user's email must match the address the invitation was sent to. ## Events -This package does not currently dispatch any custom events. +The package dispatches the following events, which a host application can listen for: + +* `MemberInvited` - an invitation was created (carries the workspace and the invitation). Listen for this to deliver the invitation email. +* `MemberJoined` - an invited user accepted and joined (carries the workspace, the member, and the role). +* `WorkspaceSwitched` - a user switched their current workspace (carries the new workspace, the user, and the previous workspace). ## Publishing Assets diff --git a/composer.json b/composer.json index 878cd62..faf65b2 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Workspace management package for Laravel applications", "type": "library", "license": "MIT", - "version": "1.0.3", + "version": "1.1.0", "authors": [ { "name": "WhileSmart", diff --git a/routes/workspaces.php b/routes/workspaces.php index 20fe008..184c2d5 100644 --- a/routes/workspaces.php +++ b/routes/workspaces.php @@ -27,3 +27,6 @@ Route::get('/workspaces/{workspace}/invitations', [WorkspaceController::class, 'invitations']); Route::delete('/workspaces/{workspace}/invitations/{invitation}', [WorkspaceController::class, 'cancelInvitation']); + +Route::post('/workspaces/invitations/{token}/accept', [WorkspaceController::class, 'acceptInvitation']); +Route::post('/workspaces/invitations/{token}/decline', [WorkspaceController::class, 'declineInvitation']); diff --git a/src/Http/Controllers/WorkspaceController.php b/src/Http/Controllers/WorkspaceController.php index 381e842..0fa93c4 100644 --- a/src/Http/Controllers/WorkspaceController.php +++ b/src/Http/Controllers/WorkspaceController.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Validator; use Whilesmart\Workspaces\Enums\Role; use Whilesmart\Workspaces\Enums\WorkspaceType; +use Whilesmart\Workspaces\Events\MemberInvited; use Whilesmart\Workspaces\Models\Workspace; use Whilesmart\Workspaces\Models\WorkspaceInvitation; @@ -231,6 +232,8 @@ public function inviteMember(Request $request, Workspace $workspace): JsonRespon 'invited_by_user_id' => auth()->id(), ]); + MemberInvited::dispatch($workspace, $invitation); + return response()->json([ 'success' => true, 'message' => 'Invitation sent successfully', @@ -285,6 +288,74 @@ public function cancelInvitation(Workspace $workspace, WorkspaceInvitation $invi ]); } + public function acceptInvitation(string $token): JsonResponse + { + $user = auth()->user(); + + if (! $user) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $invitation = WorkspaceInvitation::where('token', $token)->firstOrFail(); + + if ($invitation->email !== $user->email) { + return response()->json(['error' => 'This invitation was sent to a different email address'], 403); + } + + if (! $invitation->isValid()) { + $reason = $invitation->isExpired() + ? 'This invitation has expired' + : 'This invitation has already been actioned'; + + return response()->json(['error' => $reason], 422); + } + + if (! $user->acceptInvitation($invitation)) { + return response()->json(['error' => 'Unable to accept invitation'], 422); + } + + return response()->json([ + 'success' => true, + 'message' => 'Invitation accepted', + 'data' => [ + 'workspace' => [ + 'id' => $invitation->workspace->id, + 'slug' => $invitation->workspace->slug, + 'name' => $invitation->workspace->name, + ], + 'role' => $invitation->role, + ], + ]); + } + + public function declineInvitation(string $token): JsonResponse + { + $user = auth()->user(); + + if (! $user) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $invitation = WorkspaceInvitation::where('token', $token)->firstOrFail(); + + if ($invitation->email !== $user->email) { + return response()->json(['error' => 'This invitation was sent to a different email address'], 403); + } + + if (! $invitation->isPending()) { + return response()->json(['error' => 'This invitation has already been actioned'], 422); + } + + if (! $user->declineInvitation($invitation)) { + return response()->json(['error' => 'Unable to decline invitation'], 422); + } + + return response()->json([ + 'success' => true, + 'message' => 'Invitation declined', + ]); + } + public function removeMember(Workspace $workspace, string $userId): JsonResponse { if (! $this->userCanManage($workspace)) { diff --git a/src/Traits/HasWorkspaces.php b/src/Traits/HasWorkspaces.php index 8a34b18..c98bb7a 100644 --- a/src/Traits/HasWorkspaces.php +++ b/src/Traits/HasWorkspaces.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; use Whilesmart\Workspaces\Enums\Role; use Whilesmart\Workspaces\Enums\WorkspaceType; +use Whilesmart\Workspaces\Events\MemberJoined; use Whilesmart\Workspaces\Events\WorkspaceSwitched; use Whilesmart\Workspaces\Models\Workspace; use Whilesmart\Workspaces\Models\WorkspaceInvitation; @@ -130,6 +131,8 @@ public function acceptInvitation(WorkspaceInvitation $invitation): bool $invitation->role ); + MemberJoined::dispatch($invitation->workspace, $this, $invitation->role); + return true; } diff --git a/tests/Feature/WorkspaceApiTest.php b/tests/Feature/WorkspaceApiTest.php index 2dd36df..b1036de 100644 --- a/tests/Feature/WorkspaceApiTest.php +++ b/tests/Feature/WorkspaceApiTest.php @@ -2,12 +2,16 @@ namespace Tests\Feature; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Hash; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; use Whilesmart\Roles\Models\Role; use Whilesmart\Roles\Models\RoleAssignment; +use Whilesmart\Workspaces\Events\MemberInvited; +use Whilesmart\Workspaces\Events\MemberJoined; use Whilesmart\Workspaces\Models\Workspace; +use Whilesmart\Workspaces\Models\WorkspaceInvitation; use Workbench\App\Models\User; class WorkspaceApiTest extends TestCase @@ -300,4 +304,126 @@ public function workspace_creation_requires_name() $response->assertStatus(422) ->assertJsonValidationErrors(['name']); } + + private function createInvitation(Workspace $workspace, string $email, array $attributes = []): WorkspaceInvitation + { + return WorkspaceInvitation::create(array_merge([ + 'workspace_id' => $workspace->id, + 'email' => $email, + 'role' => 'member', + ], $attributes)); + } + + #[Test] + public function invited_user_can_accept_invitation() + { + $owner = $this->createUser(['email' => 'owner@example.com']); + $workspace = $this->createWorkspaceWithOwner($owner); + $invitee = $this->createUser(['email' => 'invitee@example.com']); + $invitation = $this->createInvitation($workspace, 'invitee@example.com'); + + $response = $this->actingAs($invitee)->postJson("/workspaces/invitations/{$invitation->token}/accept"); + + $response->assertStatus(200)->assertJson(['success' => true]); + $this->assertTrue($invitee->fresh()->belongsToWorkspace($workspace)); + $this->assertNotNull($invitation->fresh()->accepted_at); + } + + #[Test] + public function user_cannot_accept_invitation_sent_to_another_email() + { + $owner = $this->createUser(['email' => 'owner@example.com']); + $workspace = $this->createWorkspaceWithOwner($owner); + $other = $this->createUser(['email' => 'other@example.com']); + $invitation = $this->createInvitation($workspace, 'invitee@example.com'); + + $response = $this->actingAs($other)->postJson("/workspaces/invitations/{$invitation->token}/accept"); + + $response->assertStatus(403); + $this->assertFalse($other->fresh()->belongsToWorkspace($workspace)); + } + + #[Test] + public function expired_invitation_cannot_be_accepted() + { + $owner = $this->createUser(['email' => 'owner@example.com']); + $workspace = $this->createWorkspaceWithOwner($owner); + $invitee = $this->createUser(['email' => 'invitee@example.com']); + $invitation = $this->createInvitation($workspace, 'invitee@example.com', ['expires_at' => now()->subDay()]); + + $response = $this->actingAs($invitee)->postJson("/workspaces/invitations/{$invitation->token}/accept"); + + $response->assertStatus(422); + $this->assertFalse($invitee->fresh()->belongsToWorkspace($workspace)); + } + + #[Test] + public function already_accepted_invitation_cannot_be_accepted_again() + { + $owner = $this->createUser(['email' => 'owner@example.com']); + $workspace = $this->createWorkspaceWithOwner($owner); + $invitee = $this->createUser(['email' => 'invitee@example.com']); + $invitation = $this->createInvitation($workspace, 'invitee@example.com', ['accepted_at' => now()]); + + $response = $this->actingAs($invitee)->postJson("/workspaces/invitations/{$invitation->token}/accept"); + + $response->assertStatus(422); + } + + #[Test] + public function accepting_an_unknown_token_returns_not_found() + { + $invitee = $this->createUser(['email' => 'invitee@example.com']); + + $response = $this->actingAs($invitee)->postJson('/workspaces/invitations/does-not-exist/accept'); + + $response->assertStatus(404); + } + + #[Test] + public function invited_user_can_decline_invitation() + { + $owner = $this->createUser(['email' => 'owner@example.com']); + $workspace = $this->createWorkspaceWithOwner($owner); + $invitee = $this->createUser(['email' => 'invitee@example.com']); + $invitation = $this->createInvitation($workspace, 'invitee@example.com'); + + $response = $this->actingAs($invitee)->postJson("/workspaces/invitations/{$invitation->token}/decline"); + + $response->assertStatus(200)->assertJson(['success' => true]); + $this->assertNotNull($invitation->fresh()->declined_at); + $this->assertFalse($invitee->fresh()->belongsToWorkspace($workspace)); + } + + #[Test] + public function inviting_a_member_dispatches_member_invited_event() + { + Event::fake([MemberInvited::class]); + + $owner = $this->createUser(['email' => 'owner@example.com']); + $workspace = $this->createWorkspaceWithOwner($owner); + + $this->actingAs($owner)->postJson("/workspaces/{$workspace->slug}/members/invite", [ + 'email' => 'newmember@example.com', + 'role' => 'member', + ])->assertStatus(201); + + Event::assertDispatched(MemberInvited::class); + } + + #[Test] + public function accepting_an_invitation_dispatches_member_joined_event() + { + Event::fake([MemberJoined::class]); + + $owner = $this->createUser(['email' => 'owner@example.com']); + $workspace = $this->createWorkspaceWithOwner($owner); + $invitee = $this->createUser(['email' => 'invitee@example.com']); + $invitation = $this->createInvitation($workspace, 'invitee@example.com'); + + $this->actingAs($invitee)->postJson("/workspaces/invitations/{$invitation->token}/accept") + ->assertStatus(200); + + Event::assertDispatched(MemberJoined::class); + } }