Skip to content

Commit 075c089

Browse files
committed
Fix API Teams non-member 404, signed invitation accept, factory cleanup
Make TeamMemberController@destroy 404 when the targeted user has no membership instead of silently returning 204. Move invitation accept to GET /invitations/{invitation}/accept under signed middleware with a 3-day TTL, validate state inline, and resolve the user by case-insensitive email match. Add a kit-local TeamInvitation notification override that emits the signed URL via URL::temporarySignedRoute, leaving Inertia/Livewire variants on the original GET link. Drop the manual Str::slug build in UserFactory's afterCreating block to match the Fortify and WorkOS variant convention.
1 parent 13eb95b commit 075c089

7 files changed

Lines changed: 291 additions & 42 deletions

File tree

kits/API/Teams/app/Http/Controllers/Teams/AcceptTeamInvitationController.php

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,37 @@
33
namespace App\Http\Controllers\Teams;
44

55
use App\Http\Controllers\Controller;
6-
use App\Http\Requests\Teams\AcceptTeamInvitationRequest;
76
use App\Models\TeamInvitation;
7+
use App\Models\User;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\Request;
810
use Illuminate\Http\Response;
911
use Illuminate\Support\Facades\DB;
10-
use Knuckles\Scribe\Attributes\Authenticated;
12+
use Illuminate\Validation\ValidationException;
1113
use Knuckles\Scribe\Attributes\Endpoint;
1214
use Knuckles\Scribe\Attributes\Group;
1315
use Knuckles\Scribe\Attributes\Response as ScribeResponse;
1416

1517
#[Group('Team Invitations')]
16-
#[Authenticated]
1718
class AcceptTeamInvitationController extends Controller
1819
{
19-
#[Endpoint('Accept an invitation', 'Accept a team invitation as the authenticated user.')]
20-
#[ScribeResponse(status: Response::HTTP_NO_CONTENT, description: 'No Content')]
21-
public function __invoke(AcceptTeamInvitationRequest $request, TeamInvitation $invitation): Response
20+
#[Endpoint('Accept an invitation', 'Accept a team invitation using the signed link delivered to the invitee by email. The link must be valid, unexpired, and resolve to an existing user account.')]
21+
#[ScribeResponse(['message' => 'Invitation accepted successfully.'], description: 'Invitation accepted')]
22+
#[ScribeResponse(status: Response::HTTP_FORBIDDEN, description: 'Invalid signature or no matching user.')]
23+
#[ScribeResponse(status: Response::HTTP_UNPROCESSABLE_ENTITY, description: 'Invitation already accepted or expired.')]
24+
public function __invoke(Request $request, TeamInvitation $invitation): JsonResponse
2225
{
23-
$user = $request->user();
26+
if ($invitation->isAccepted() || $invitation->isExpired()) {
27+
throw ValidationException::withMessages([
28+
'invitation' => __('This invitation is no longer valid.'),
29+
]);
30+
}
31+
32+
$user = User::query()
33+
->whereRaw('LOWER(email) = ?', [strtolower($invitation->email)])
34+
->first();
35+
36+
abort_if($user === null, Response::HTTP_FORBIDDEN);
2437

2538
DB::transaction(function () use ($user, $invitation) {
2639
$invitation->team->memberships()->firstOrCreate(
@@ -31,6 +44,6 @@ public function __invoke(AcceptTeamInvitationRequest $request, TeamInvitation $i
3144
$invitation->update(['accepted_at' => now()]);
3245
});
3346

34-
return response()->noContent();
47+
return response()->json(['message' => __('Invitation accepted successfully.')]);
3548
}
3649
}

kits/API/Teams/app/Http/Controllers/Teams/TeamMemberController.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public function update(UpdateTeamMemberRequest $request, Team $team, User $user)
4343

4444
#[Endpoint('Remove a member', 'Remove a member from the team. The team owner cannot be removed.')]
4545
#[ScribeResponse(status: Response::HTTP_NO_CONTENT, description: 'No Content')]
46+
#[ScribeResponse(status: Response::HTTP_NOT_FOUND, description: 'The user is not a member of the team.')]
4647
public function destroy(Team $team, User $user): Response
4748
{
4849
Gate::authorize('removeMember', $team);
@@ -51,6 +52,7 @@ public function destroy(Team $team, User $user): Response
5152

5253
$team->memberships()
5354
->where('user_id', $user->id)
55+
->firstOrFail()
5456
->delete();
5557

5658
return response()->noContent();
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
namespace App\Notifications\Teams;
4+
5+
use App\Models\TeamInvitation as TeamInvitationModel;
6+
use Illuminate\Bus\Queueable;
7+
use Illuminate\Contracts\Queue\ShouldQueue;
8+
use Illuminate\Notifications\Messages\MailMessage;
9+
use Illuminate\Notifications\Notification;
10+
use Illuminate\Support\Facades\URL;
11+
12+
class TeamInvitation extends Notification implements ShouldQueue
13+
{
14+
use Queueable;
15+
16+
/**
17+
* Create a new notification instance.
18+
*/
19+
public function __construct(public TeamInvitationModel $invitation)
20+
{
21+
//
22+
}
23+
24+
/**
25+
* Get the notification's delivery channels.
26+
*
27+
* @return array<int, string>
28+
*/
29+
public function via(object $notifiable): array
30+
{
31+
return ['mail'];
32+
}
33+
34+
/**
35+
* Get the mail representation of the notification.
36+
*/
37+
public function toMail(object $notifiable): MailMessage
38+
{
39+
$team = $this->invitation->team;
40+
$inviter = $this->invitation->inviter;
41+
42+
return (new MailMessage)
43+
->subject(__("You've been invited to join :teamName", ['teamName' => $team->name]))
44+
->line(__(':inviterName has invited you to join the :teamName team.', [
45+
'inviterName' => $inviter->name,
46+
'teamName' => $team->name,
47+
]))
48+
->action(__('Accept invitation'), URL::temporarySignedRoute(
49+
'invitations.accept',
50+
now()->addDays(3),
51+
$this->invitation,
52+
));
53+
}
54+
55+
/**
56+
* Get the array representation of the notification.
57+
*
58+
* @return array<string, mixed>
59+
*/
60+
public function toArray(object $notifiable): array
61+
{
62+
return [
63+
'invitation_id' => $this->invitation->id,
64+
'team_id' => $this->invitation->team_id,
65+
'team_name' => $this->invitation->team->name,
66+
'role' => $this->invitation->role->value,
67+
];
68+
}
69+
}

kits/API/Teams/database/factories/UserFactory.php

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,8 @@ public function definition(): array
4141
public function configure(): static
4242
{
4343
return $this->afterCreating(function (User $user) {
44-
$name = $user->name."'s Team";
45-
4644
$team = Team::factory()->personal()->create([
47-
'name' => $name,
48-
'slug' => Str::slug($name),
45+
'name' => $user->name."'s Team",
4946
]);
5047

5148
$team->members()->attach($user, [

kits/API/Teams/routes/teams.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
use App\Http\Middleware\EnsureTeamMembership;
88
use Illuminate\Support\Facades\Route;
99

10-
Route::middleware('auth:sanctum')->group(function () {
11-
Route::post('invitations/{invitation}/accept', AcceptTeamInvitationController::class)
12-
->name('invitations.accept');
10+
Route::get('invitations/{invitation}/accept', AcceptTeamInvitationController::class)
11+
->middleware('signed')
12+
->name('invitations.accept');
1313

14+
Route::middleware('auth:sanctum')->group(function () {
1415
Route::middleware('verified')->group(function () {
1516
Route::get('teams', [TeamController::class, 'index'])->name('teams.index');
1617
Route::post('teams', [TeamController::class, 'store'])->name('teams.store');

0 commit comments

Comments
 (0)