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();

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.

Using firstOrFail() with a non-ID lookup in an API context is generally fine, but consider whether you want to expose a 404 for an invalid token or a more specific 422 if the token simply doesn't exist. For tokens, 404 is appropriate, but ensure you have a custom exception handler if you want to return a JSON error message instead of the default HTML 404 page.

Suggested change
$invitation = WorkspaceInvitation::where('token', $token)->firstOrFail();
$invitation = WorkspaceInvitation::where('token', $token)->first();
if (! $invitation) {
return response()->json(['error' => 'Invalid or expired invitation token'], 404);
}


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)) {
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