|
2 | 2 |
|
3 | 3 | namespace App\Http\Controllers; |
4 | 4 |
|
5 | | -use App\Models\Team; |
| 5 | +use App\Enums\TeamUserStatus; |
| 6 | +use App\Http\Requests\InviteTeamUserRequest; |
| 7 | +use App\Jobs\RevokeTeamUserAccessJob; |
6 | 8 | use App\Models\TeamUser; |
| 9 | +use App\Notifications\TeamInvitation; |
| 10 | +use App\Notifications\TeamUserRemoved; |
7 | 11 | use Illuminate\Http\RedirectResponse; |
8 | | -use Illuminate\Http\Request; |
9 | 12 | use Illuminate\Support\Facades\Auth; |
10 | | -use Illuminate\Support\Str; |
| 13 | +use Illuminate\Support\Facades\Notification; |
| 14 | +use Illuminate\Support\Facades\RateLimiter; |
11 | 15 |
|
12 | 16 | class TeamUserController extends Controller |
13 | 17 | { |
14 | 18 | public function __construct() |
15 | 19 | { |
16 | | - $this->middleware('auth'); |
| 20 | + $this->middleware('auth')->except('accept'); |
17 | 21 | } |
18 | 22 |
|
19 | | - public function store(Request $request, Team $team): RedirectResponse |
| 23 | + public function invite(InviteTeamUserRequest $request): RedirectResponse |
20 | 24 | { |
21 | 25 | $user = Auth::user(); |
| 26 | + $team = $user->ownedTeam; |
22 | 27 |
|
23 | | - if ($team->user_id !== $user->id || ! $user->hasUltraAccess()) { |
24 | | - abort(403); |
| 28 | + if (! $team) { |
| 29 | + return back()->with('error', 'You do not have a team.'); |
25 | 30 | } |
26 | 31 |
|
27 | | - $request->validate([ |
28 | | - 'email' => ['required', 'email', 'max:255'], |
29 | | - ]); |
| 32 | + if ($team->is_suspended) { |
| 33 | + return back()->with('error', 'Your team is currently suspended.'); |
| 34 | + } |
30 | 35 |
|
31 | | - if (! $team->hasAvailableSeats()) { |
32 | | - return back()->withErrors([ |
33 | | - 'email' => 'All seats are occupied. Add more seats in your team settings to invite more members.', |
34 | | - ]); |
| 36 | + // Rate limit: 5 invites per minute per team |
| 37 | + $rateLimitKey = "team-invite:{$team->id}"; |
| 38 | + if (RateLimiter::tooManyAttempts($rateLimitKey, 5)) { |
| 39 | + return back()->with('error', 'Too many invitations sent. Please wait a moment.'); |
35 | 40 | } |
| 41 | + RateLimiter::hit($rateLimitKey, 60); |
| 42 | + |
| 43 | + $email = $request->validated()['email']; |
36 | 44 |
|
37 | | - // Check for existing invitation or membership |
38 | | - if ($team->members()->where('email', $request->email)->exists()) { |
39 | | - return back()->withErrors([ |
40 | | - 'email' => 'This email already has an invitation or is already a member.', |
41 | | - ]); |
| 45 | + // Check for duplicate (active or pending) |
| 46 | + $existingMember = $team->users() |
| 47 | + ->where('email', $email) |
| 48 | + ->whereIn('status', [TeamUserStatus::Pending, TeamUserStatus::Active]) |
| 49 | + ->first(); |
| 50 | + |
| 51 | + if ($existingMember) { |
| 52 | + return back()->with('error', 'This email has already been invited or is an active member.'); |
42 | 53 | } |
43 | 54 |
|
44 | | - TeamUser::create([ |
45 | | - 'team_id' => $team->id, |
46 | | - 'email' => $request->email, |
47 | | - 'role' => 'member', |
48 | | - 'status' => 'pending', |
49 | | - 'invitation_token' => Str::random(64), |
| 55 | + // Hard cap at 10 for initial release |
| 56 | + if ($team->isOverIncludedLimit()) { |
| 57 | + return back()->with('error', 'Your team has reached the maximum of 10 members. Need more seats? Contact us.'); |
| 58 | + } |
| 59 | + |
| 60 | + $member = $team->users()->create([ |
| 61 | + 'email' => $email, |
| 62 | + 'invitation_token' => bin2hex(random_bytes(32)), |
50 | 63 | 'invited_at' => now(), |
51 | 64 | ]); |
52 | 65 |
|
53 | | - return back()->with('success', 'Invitation sent successfully.'); |
| 66 | + Notification::route('mail', $email) |
| 67 | + ->notify(new TeamInvitation($member)); |
| 68 | + |
| 69 | + return back()->with('success', "Invitation sent to {$email}."); |
54 | 70 | } |
55 | 71 |
|
56 | | - public function destroy(Team $team, TeamUser $teamUser): RedirectResponse |
| 72 | + public function remove(TeamUser $teamUser): RedirectResponse |
57 | 73 | { |
58 | 74 | $user = Auth::user(); |
59 | 75 |
|
60 | | - if ($team->user_id !== $user->id || ! $user->hasUltraAccess()) { |
61 | | - abort(403); |
| 76 | + if (! $user->ownedTeam || $teamUser->team_id !== $user->ownedTeam->id) { |
| 77 | + return back()->with('error', 'You are not authorized to remove this member.'); |
62 | 78 | } |
63 | 79 |
|
64 | | - if ($teamUser->team_id !== $team->id) { |
65 | | - abort(404); |
| 80 | + $teamUser->remove(); |
| 81 | + |
| 82 | + Notification::route('mail', $teamUser->email) |
| 83 | + ->notify(new TeamUserRemoved($teamUser)); |
| 84 | + |
| 85 | + if ($teamUser->user_id) { |
| 86 | + dispatch(new RevokeTeamUserAccessJob($teamUser->user_id)); |
| 87 | + } |
| 88 | + |
| 89 | + return back()->with('success', "{$teamUser->email} has been removed from the team."); |
| 90 | + } |
| 91 | + |
| 92 | + public function resend(TeamUser $teamUser): RedirectResponse |
| 93 | + { |
| 94 | + $user = Auth::user(); |
| 95 | + |
| 96 | + if (! $user->ownedTeam || $teamUser->team_id !== $user->ownedTeam->id) { |
| 97 | + return back()->with('error', 'You are not authorized to resend this invitation.'); |
| 98 | + } |
| 99 | + |
| 100 | + if (! $teamUser->isPending()) { |
| 101 | + return back()->with('error', 'This invitation cannot be resent.'); |
| 102 | + } |
| 103 | + |
| 104 | + // Rate limit: 1 resend per minute per member |
| 105 | + $rateLimitKey = "team-resend:{$teamUser->id}"; |
| 106 | + if (RateLimiter::tooManyAttempts($rateLimitKey, 1)) { |
| 107 | + return back()->with('error', 'Please wait before resending this invitation.'); |
| 108 | + } |
| 109 | + RateLimiter::hit($rateLimitKey, 60); |
| 110 | + |
| 111 | + Notification::route('mail', $teamUser->email) |
| 112 | + ->notify(new TeamInvitation($teamUser)); |
| 113 | + |
| 114 | + return back()->with('success', "Invitation resent to {$teamUser->email}."); |
| 115 | + } |
| 116 | + |
| 117 | + public function accept(string $token): RedirectResponse |
| 118 | + { |
| 119 | + $teamUser = TeamUser::where('invitation_token', $token) |
| 120 | + ->where('status', TeamUserStatus::Pending) |
| 121 | + ->first(); |
| 122 | + |
| 123 | + if (! $teamUser) { |
| 124 | + return to_route('dashboard') |
| 125 | + ->with('error', 'This invitation is invalid or has already been used.'); |
| 126 | + } |
| 127 | + |
| 128 | + $user = Auth::user(); |
| 129 | + |
| 130 | + if ($user) { |
| 131 | + // Authenticated user |
| 132 | + if (strtolower($user->email) !== strtolower($teamUser->email)) { |
| 133 | + return to_route('dashboard') |
| 134 | + ->with('error', 'This invitation was sent to a different email address.'); |
| 135 | + } |
| 136 | + |
| 137 | + $teamUser->accept($user); |
| 138 | + |
| 139 | + return to_route('dashboard') |
| 140 | + ->with('success', "You've joined {$teamUser->team->name}!"); |
66 | 141 | } |
67 | 142 |
|
68 | | - $teamUser->delete(); |
| 143 | + // Not authenticated — store token in session and redirect to login |
| 144 | + session(['pending_team_invitation_token' => $token]); |
69 | 145 |
|
70 | | - return back()->with('success', 'Member removed successfully.'); |
| 146 | + return to_route('customer.login') |
| 147 | + ->with('message', 'Please log in or register to accept your team invitation.'); |
71 | 148 | } |
72 | 149 | } |
0 commit comments