Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
35 changes: 28 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions routes/workspaces.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
71 changes: 71 additions & 0 deletions src/Http/Controllers/WorkspaceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -285,6 +288,74 @@ public function cancelInvitation(Workspace $workspace, WorkspaceInvitation $invi
]);
}

public function acceptInvitation(string $token): JsonResponse
{
$user = auth()->user();

Comment on lines +293 to +294

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The authentication check if (! $user) is redundant here because this route should be protected by the auth:sanctum (or similar) middleware. Removing it simplifies the controller and leverages the framework's built-in handling.

Suggested change
$user = auth()->user();
$user = auth()->user();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consistency: As with acceptInvitation, the manual check for an authenticated user can be removed assuming the route is protected by middleware.

Suggested change
$user = auth()->user();

if (! $user) {
return response()->json(['error' => 'Unauthorized'], 401);
}

$invitation = WorkspaceInvitation::where('token', $token)->firstOrFail();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retrieving the invitation first by token and then checking the email separately is safe, but for performance and conciseness, you can include the email check in the query. This also prevents leaking existence of a token to the wrong user via 403 vs 404.

Suggested change
$invitation = WorkspaceInvitation::where('token', $token)->firstOrFail();
$invitation = WorkspaceInvitation::where('token', $token)->where('email', $user->email)->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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is safer to wrap the acceptance logic in a database transaction to ensure that the workspace member record creation and invitation status update happen atomically.

Suggested change
if (! $user->acceptInvitation($invitation)) {
try {
$accepted = \Illuminate\Support\Facades\DB::transaction(fn() => $user->acceptInvitation($invitation));
if (! $accepted) throw new \Exception();
} catch (\Exception $e) {
return response()->json(['error' => 'Unable to accept invitation'], 422);
}

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)) {
Expand Down
3 changes: 3 additions & 0 deletions src/Traits/HasWorkspaces.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -130,6 +131,8 @@ public function acceptInvitation(WorkspaceInvitation $invitation): bool
$invitation->role
);

MemberJoined::dispatch($invitation->workspace, $this, $invitation->role);

return true;
}

Expand Down
126 changes: 126 additions & 0 deletions tests/Feature/WorkspaceApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
Loading