Skip to content

Commit 0880f8f

Browse files
committed
Teams
1 parent c0cc24c commit 0880f8f

33 files changed

Lines changed: 1647 additions & 909 deletions

app/Enums/TeamUserRole.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace App\Enums;
4+
5+
enum TeamUserRole: string
6+
{
7+
case Owner = 'owner';
8+
case Member = 'member';
9+
}

app/Enums/TeamUserStatus.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace App\Enums;
4+
5+
enum TeamUserStatus: string
6+
{
7+
case Pending = 'pending';
8+
case Active = 'active';
9+
case Removed = 'removed';
10+
}

app/Http/Controllers/Api/PluginAccessController.php

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -151,19 +151,15 @@ protected function getAccessiblePlugins(User $user): array
151151
}
152152
}
153153

154-
// Plugins accessible via team membership
155-
$teamOwner = $user->getTeamOwner();
156-
157-
if ($teamOwner) {
158-
$teamPlugins = $teamOwner->pluginLicenses()
159-
->active()
160-
->with('plugin:id,name')
161-
->get()
162-
->pluck('plugin')
163-
->filter()
164-
->unique('id');
165-
166-
foreach ($teamPlugins as $plugin) {
154+
// Ultra team members get access to all official paid plugins
155+
if ($user->isUltraTeamMember()) {
156+
$officialPlugins = Plugin::query()
157+
->where('type', \App\Enums\PluginType::Paid)
158+
->where('is_official', true)
159+
->whereNotNull('name')
160+
->get(['name']);
161+
162+
foreach ($officialPlugins as $plugin) {
167163
if (! collect($plugins)->contains('name', $plugin->name)) {
168164
$plugins[] = [
169165
'name' => $plugin->name,

app/Http/Controllers/Auth/CustomerAuthController.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
namespace App\Http\Controllers\Auth;
44

5+
use App\Enums\TeamUserStatus;
56
use App\Http\Controllers\Controller;
67
use App\Http\Requests\Auth\LoginRequest;
78
use App\Models\Plugin;
9+
use App\Models\TeamUser;
810
use App\Models\User;
911
use App\Services\CartService;
1012
use Illuminate\Http\RedirectResponse;
@@ -46,6 +48,9 @@ public function register(Request $request): RedirectResponse
4648
// Transfer guest cart to user
4749
$this->cartService->transferGuestCartToUser($user);
4850

51+
// Check for pending team invitation
52+
$this->acceptPendingTeamInvitation($user);
53+
4954
// Check for pending add-to-cart action
5055
$pendingPluginId = session()->pull('pending_add_to_cart');
5156
if ($pendingPluginId) {
@@ -77,6 +82,9 @@ public function login(LoginRequest $request): RedirectResponse
7782
// Transfer guest cart to user
7883
$this->cartService->transferGuestCartToUser($user);
7984

85+
// Check for pending team invitation
86+
$this->acceptPendingTeamInvitation($user);
87+
8088
// Check for pending add-to-cart action
8189
$pendingPluginId = session()->pull('pending_add_to_cart');
8290
if ($pendingPluginId) {
@@ -155,4 +163,23 @@ function ($user, $password): void {
155163
? to_route('customer.login')->with('status', __($status))
156164
: back()->withErrors(['email' => [__($status)]]);
157165
}
166+
167+
private function acceptPendingTeamInvitation(User $user): void
168+
{
169+
$token = session()->pull('pending_team_invitation_token');
170+
171+
if (! $token) {
172+
return;
173+
}
174+
175+
$teamUser = TeamUser::where('invitation_token', $token)
176+
->where('email', $user->email)
177+
->where('status', TeamUserStatus::Pending)
178+
->first();
179+
180+
if ($teamUser) {
181+
$teamUser->accept($user);
182+
session()->flash('success', "You've joined {$teamUser->team->name}!");
183+
}
184+
}
158185
}

app/Http/Controllers/CustomerLicenseController.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ public function index(): View
9292
// Total purchases (licenses + plugins)
9393
$totalPurchases = $licenseCount + $pluginLicenseCount;
9494

95+
// Team info
96+
$ownedTeam = $user->ownedTeam;
97+
$hasTeam = $ownedTeam !== null;
98+
$teamName = $ownedTeam?->name;
99+
$teamMemberCount = $ownedTeam?->activeUserCount() ?? 0;
100+
$hasMaxAccess = $user->hasActiveUltraSubscription();
101+
95102
return view('customer.dashboard', compact(
96103
'licenseCount',
97104
'isEapCustomer',
@@ -102,6 +109,10 @@ public function index(): View
102109
'connectedAccountsCount',
103110
'connectedAccountsDescription',
104111
'totalPurchases',
112+
'hasTeam',
113+
'teamName',
114+
'teamMemberCount',
115+
'hasMaxAccess'
105116
));
106117
}
107118

app/Http/Controllers/GitHubIntegrationController.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,11 @@ public function requestClaudePluginsAccess(): RedirectResponse
164164
return back()->with('error', 'Please connect your GitHub account first.');
165165
}
166166

167-
// Check if user has a Plugin Dev Kit license
167+
// Check if user has a Plugin Dev Kit license or is an Ultra team member
168168
$pluginDevKit = Product::where('slug', 'plugin-dev-kit')->first();
169169

170-
if (! $pluginDevKit || ! $user->hasProductLicense($pluginDevKit)) {
171-
return back()->with('error', 'You need a Plugin Dev Kit license to access the claude-code repository.');
170+
if (! $user->isUltraTeamMember() && (! $pluginDevKit || ! $user->hasProductLicense($pluginDevKit))) {
171+
return back()->with('error', 'You need a Plugin Dev Kit license or Ultra team membership to access the claude-code repository.');
172172
}
173173

174174
$github = GitHubOAuth::make();
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use Illuminate\Http\RedirectResponse;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\Auth;
8+
use Illuminate\View\View;
9+
10+
class TeamController extends Controller
11+
{
12+
public function __construct()
13+
{
14+
$this->middleware('auth');
15+
}
16+
17+
public function index(): View
18+
{
19+
$user = Auth::user();
20+
$team = $user->ownedTeam;
21+
$membership = $user->activeTeamMembership();
22+
23+
return view('customer.team.index', compact('team', 'membership'));
24+
}
25+
26+
public function store(Request $request): RedirectResponse
27+
{
28+
$user = Auth::user();
29+
30+
if (! $user->hasActiveUltraSubscription()) {
31+
return back()->with('error', 'You need an active Ultra subscription to create a team.');
32+
}
33+
34+
if ($user->ownedTeam) {
35+
return back()->with('error', 'You already have a team.');
36+
}
37+
38+
$request->validate([
39+
'name' => ['required', 'string', 'max:255'],
40+
]);
41+
42+
$user->ownedTeam()->create([
43+
'name' => $request->name,
44+
]);
45+
46+
return to_route('customer.team.index')
47+
->with('success', 'Team created successfully!');
48+
}
49+
}

app/Http/Controllers/TeamUserController.php

Lines changed: 110 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,71 +2,148 @@
22

33
namespace App\Http\Controllers;
44

5-
use App\Models\Team;
5+
use App\Enums\TeamUserStatus;
6+
use App\Http\Requests\InviteTeamUserRequest;
7+
use App\Jobs\RevokeTeamUserAccessJob;
68
use App\Models\TeamUser;
9+
use App\Notifications\TeamInvitation;
10+
use App\Notifications\TeamUserRemoved;
711
use Illuminate\Http\RedirectResponse;
8-
use Illuminate\Http\Request;
912
use Illuminate\Support\Facades\Auth;
10-
use Illuminate\Support\Str;
13+
use Illuminate\Support\Facades\Notification;
14+
use Illuminate\Support\Facades\RateLimiter;
1115

1216
class TeamUserController extends Controller
1317
{
1418
public function __construct()
1519
{
16-
$this->middleware('auth');
20+
$this->middleware('auth')->except('accept');
1721
}
1822

19-
public function store(Request $request, Team $team): RedirectResponse
23+
public function invite(InviteTeamUserRequest $request): RedirectResponse
2024
{
2125
$user = Auth::user();
26+
$team = $user->ownedTeam;
2227

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.');
2530
}
2631

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+
}
3035

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.');
3540
}
41+
RateLimiter::hit($rateLimitKey, 60);
42+
43+
$email = $request->validated()['email'];
3644

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.');
4253
}
4354

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)),
5063
'invited_at' => now(),
5164
]);
5265

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}.");
5470
}
5571

56-
public function destroy(Team $team, TeamUser $teamUser): RedirectResponse
72+
public function remove(TeamUser $teamUser): RedirectResponse
5773
{
5874
$user = Auth::user();
5975

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.');
6278
}
6379

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}!");
66141
}
67142

68-
$teamUser->delete();
143+
// Not authenticated — store token in session and redirect to login
144+
session(['pending_team_invitation_token' => $token]);
69145

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.');
71148
}
72149
}

0 commit comments

Comments
 (0)