diff --git a/PLAN.md b/PLAN.md
index debd517..480af39 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -305,7 +305,7 @@ Clients configure this DSN in their Sentry SDK — no changes needed on the SDK
### Milestone 2 — Usable product
- [x] 8. Project CRUD and settings pages
-- [ ] 9. Team/member management (invite users to projects)
+- [x] 9. Team/member management (invite users to projects)
- [x] 10. Stacktrace viewer with collapsible frames
- [x] 11. Breadcrumbs, tags, context panels on issue detail
- [x] 12. Filters, search, bulk actions on issues list
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
index 8677cd5..bd3e17e 100644
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -2,7 +2,11 @@
namespace App\Http\Controllers;
-abstract class Controller
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Foundation\Validation\ValidatesRequests;
+use Illuminate\Routing\Controller as BaseController;
+
+abstract class Controller extends BaseController
{
- //
+ use AuthorizesRequests, ValidatesRequests;
}
diff --git a/app/Http/Controllers/IssueController.php b/app/Http/Controllers/IssueController.php
index fad7a35..2a7787a 100644
--- a/app/Http/Controllers/IssueController.php
+++ b/app/Http/Controllers/IssueController.php
@@ -12,7 +12,7 @@ class IssueController extends Controller
{
public function index(Request $request, Project $project): View
{
- $this->authorizeProject($request, $project);
+ $this->authorize('view', $project);
$query = $project->issues();
@@ -69,7 +69,7 @@ class IssueController extends Controller
public function show(Request $request, Project $project, Issue $issue): View
{
- $this->authorizeProject($request, $project);
+ $this->authorize('view', $project);
abort_unless($issue->project_id === $project->id, 404);
$environment = $request->query('environment');
@@ -107,7 +107,7 @@ class IssueController extends Controller
public function updateStatus(Request $request, Project $project, Issue $issue): RedirectResponse
{
- $this->authorizeProject($request, $project);
+ $this->authorize('view', $project);
abort_unless($issue->project_id === $project->id, 404);
$action = $request->input('action');
@@ -125,7 +125,7 @@ class IssueController extends Controller
public function bulkAction(Request $request, Project $project): RedirectResponse
{
- $this->authorizeProject($request, $project);
+ $this->authorize('view', $project);
$validated = $request->validate([
'action' => 'required|in:resolve,ignore,mute,delete',
@@ -147,12 +147,4 @@ class IssueController extends Controller
return back()->with('status', "{$count} issue(s) {$validated['action']}d.");
}
-
- private function authorizeProject(Request $request, Project $project): void
- {
- abort_unless(
- $request->user()->projects()->where('projects.id', $project->id)->exists(),
- 403,
- );
- }
}
diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php
index 4ef12f9..8e3df48 100644
--- a/app/Http/Controllers/ProjectController.php
+++ b/app/Http/Controllers/ProjectController.php
@@ -43,14 +43,14 @@ class ProjectController extends Controller
public function settings(Request $request, Project $project): View
{
- $this->authorizeProject($request, $project);
+ $this->authorize('view', $project);
return view('projects.settings', compact('project'));
}
public function update(Request $request, Project $project): RedirectResponse
{
- $this->authorizeProject($request, $project);
+ $this->authorize('update', $project);
$validated = $request->validate([
'name' => 'required|string|max:255',
@@ -64,7 +64,7 @@ class ProjectController extends Controller
public function regenerateKeys(Request $request, Project $project): RedirectResponse
{
- $this->authorizeProject($request, $project);
+ $this->authorize('manageSettings', $project);
$project->regenerateKeys();
@@ -73,18 +73,10 @@ class ProjectController extends Controller
public function destroy(Request $request, Project $project): RedirectResponse
{
- $this->authorizeProject($request, $project);
+ $this->authorize('delete', $project);
$project->delete();
return redirect()->route('projects.index')->with('status', 'Project deleted.');
}
-
- private function authorizeProject(Request $request, Project $project): void
- {
- abort_unless(
- $request->user()->projects()->where('projects.id', $project->id)->exists(),
- 403,
- );
- }
}
diff --git a/app/Http/Controllers/ProjectMemberController.php b/app/Http/Controllers/ProjectMemberController.php
new file mode 100644
index 0000000..28900dc
--- /dev/null
+++ b/app/Http/Controllers/ProjectMemberController.php
@@ -0,0 +1,173 @@
+authorize('view', $project);
+
+ $members = $project->users()
+ ->withPivot('role', 'created_at')
+ ->orderBy('project_user.created_at', 'desc')
+ ->get();
+
+ $pendingInvitations = $project->invitations()
+ ->whereNull('accepted_at')
+ ->where('expires_at', '>', now())
+ ->with('inviter')
+ ->orderBy('created_at', 'desc')
+ ->get();
+
+ return view('projects.members.index', [
+ 'project' => $project,
+ 'members' => $members,
+ 'pendingInvitations' => $pendingInvitations,
+ ]);
+ }
+
+ /**
+ * Invite a new member to the project
+ */
+ public function invite(Request $request, Project $project): RedirectResponse
+ {
+ $this->authorize('manageMembers', $project);
+
+ $validated = $request->validate([
+ 'email' => 'required|email',
+ 'role' => 'required|in:member,admin',
+ ]);
+
+ // Check if user is already a member
+ $existingUser = User::where('email', $validated['email'])->first();
+ if ($existingUser && $project->users()->where('users.id', $existingUser->id)->exists()) {
+ return back()->withErrors(['email' => 'This user is already a member of the project.']);
+ }
+
+ // Check if there's already a pending invitation
+ $existingInvitation = $project->invitations()
+ ->where('email', $validated['email'])
+ ->whereNull('accepted_at')
+ ->where('expires_at', '>', now())
+ ->first();
+
+ if ($existingInvitation) {
+ return back()->withErrors(['email' => 'An invitation has already been sent to this email.']);
+ }
+
+ // Create invitation
+ $invitation = $project->invitations()->create([
+ 'email' => $validated['email'],
+ 'role' => $validated['role'],
+ 'invited_by' => auth()->id(),
+ ]);
+
+ // Send invitation email
+ Mail::to($invitation->email)->send(new ProjectInvitationMail($invitation));
+
+ return back()->with('success', 'Invitation sent successfully.');
+ }
+
+ /**
+ * Accept an invitation
+ */
+ public function acceptInvitation(string $token): RedirectResponse
+ {
+ $invitation = ProjectInvitation::where('token', $token)->firstOrFail();
+
+ if ($invitation->isExpired()) {
+ return redirect()->route('dashboard')->withErrors(['error' => 'This invitation has expired.']);
+ }
+
+ if ($invitation->accepted_at) {
+ return redirect()->route('dashboard')->withErrors(['error' => 'This invitation has already been accepted.']);
+ }
+
+ // Check if the logged-in user's email matches
+ if (auth()->user()->email !== $invitation->email) {
+ return redirect()->route('dashboard')->withErrors(['error' => 'This invitation was sent to a different email address.']);
+ }
+
+ $invitation->accept(auth()->user());
+
+ return redirect()->route('projects.issues.index', $invitation->project)
+ ->with('success', 'You have joined the project successfully.');
+ }
+
+ /**
+ * Update a member's role
+ */
+ public function updateRole(Request $request, Project $project, User $user): RedirectResponse
+ {
+ $this->authorize('manageMembers', $project);
+
+ $validated = $request->validate([
+ 'role' => 'required|in:member,admin,owner',
+ ]);
+
+ // Don't allow changing your own role
+ if ($user->id === auth()->id()) {
+ return back()->withErrors(['error' => 'You cannot change your own role.']);
+ }
+
+ // Check if user is a member of the project
+ if (!$project->users()->where('users.id', $user->id)->exists()) {
+ return back()->withErrors(['error' => 'User is not a member of this project.']);
+ }
+
+ $project->users()->updateExistingPivot($user->id, ['role' => $validated['role']]);
+
+ return back()->with('success', 'Member role updated successfully.');
+ }
+
+ /**
+ * Remove a member from the project
+ */
+ public function destroy(Project $project, User $user): RedirectResponse
+ {
+ $this->authorize('manageMembers', $project);
+
+ // Don't allow removing yourself
+ if ($user->id === auth()->id()) {
+ return back()->withErrors(['error' => 'You cannot remove yourself from the project.']);
+ }
+
+ // Check if user is a member
+ if (!$project->users()->where('users.id', $user->id)->exists()) {
+ return back()->withErrors(['error' => 'User is not a member of this project.']);
+ }
+
+ $project->users()->detach($user->id);
+
+ return back()->with('success', 'Member removed from project successfully.');
+ }
+
+ /**
+ * Cancel a pending invitation
+ */
+ public function cancelInvitation(Project $project, ProjectInvitation $invitation): RedirectResponse
+ {
+ $this->authorize('manageMembers', $project);
+
+ if ($invitation->project_id !== $project->id) {
+ abort(404);
+ }
+
+ $invitation->delete();
+
+ return back()->with('success', 'Invitation cancelled successfully.');
+ }
+}
diff --git a/app/Mail/ProjectInvitationMail.php b/app/Mail/ProjectInvitationMail.php
new file mode 100644
index 0000000..12d25fe
--- /dev/null
+++ b/app/Mail/ProjectInvitationMail.php
@@ -0,0 +1,44 @@
+invitation->project->name}",
+ );
+ }
+
+ /**
+ * Get the message content definition.
+ */
+ public function content(): Content
+ {
+ return new Content(
+ markdown: 'emails.project-invitation',
+ );
+ }
+}
diff --git a/app/Models/Project.php b/app/Models/Project.php
index 856ce38..aa23d09 100644
--- a/app/Models/Project.php
+++ b/app/Models/Project.php
@@ -55,6 +55,11 @@ class Project extends Model
return $this->hasMany(AlertRule::class);
}
+ public function invitations(): HasMany
+ {
+ return $this->hasMany(ProjectInvitation::class);
+ }
+
public function regenerateKeys(): void
{
$this->update([
@@ -62,4 +67,23 @@ class Project extends Model
'dsn_secret_key' => Str::uuid()->toString(),
]);
}
+
+ public function userRole(User $user): ?string
+ {
+ return $this->users()
+ ->where('users.id', $user->id)
+ ->first()
+ ?->pivot
+ ?->role;
+ }
+
+ public function hasOwner(User $user): bool
+ {
+ return $this->userRole($user) === 'owner';
+ }
+
+ public function hasAdmin(User $user): bool
+ {
+ return in_array($this->userRole($user), ['owner', 'admin']);
+ }
}
diff --git a/app/Models/ProjectInvitation.php b/app/Models/ProjectInvitation.php
new file mode 100644
index 0000000..1650811
--- /dev/null
+++ b/app/Models/ProjectInvitation.php
@@ -0,0 +1,67 @@
+ 'datetime',
+ 'accepted_at' => 'datetime',
+ ];
+
+ protected static function booted(): void
+ {
+ static::creating(function (ProjectInvitation $invitation) {
+ $invitation->token ??= Str::random(32);
+ $invitation->expires_at ??= now()->addDays(7);
+ });
+ }
+
+ public function project(): BelongsTo
+ {
+ return $this->belongsTo(Project::class);
+ }
+
+ public function inviter(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'invited_by');
+ }
+
+ public function isExpired(): bool
+ {
+ return $this->expires_at->isPast();
+ }
+
+ public function isPending(): bool
+ {
+ return !$this->accepted_at && !$this->isExpired();
+ }
+
+ public function accept(User $user): void
+ {
+ if ($this->isExpired()) {
+ throw new \RuntimeException('Invitation has expired');
+ }
+
+ if ($this->accepted_at) {
+ throw new \RuntimeException('Invitation has already been accepted');
+ }
+
+ $this->project->users()->attach($user->id, ['role' => $this->role]);
+ $this->update(['accepted_at' => now()]);
+ }
+}
diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php
new file mode 100644
index 0000000..c86f156
--- /dev/null
+++ b/app/Policies/ProjectPolicy.php
@@ -0,0 +1,80 @@
+users()->where('users.id', $user->id)->exists();
+ }
+
+ /**
+ * Determine whether the user can create models.
+ */
+ public function create(User $user): bool
+ {
+ return true;
+ }
+
+ /**
+ * Determine whether the user can update the model.
+ */
+ public function update(User $user, Project $project): bool
+ {
+ return $this->hasRole($user, $project, ['owner', 'admin']);
+ }
+
+ /**
+ * Determine whether the user can delete the model.
+ */
+ public function delete(User $user, Project $project): bool
+ {
+ return $this->hasRole($user, $project, ['owner']);
+ }
+
+ /**
+ * Determine whether the user can manage members.
+ */
+ public function manageMembers(User $user, Project $project): bool
+ {
+ return $this->hasRole($user, $project, ['owner', 'admin']);
+ }
+
+ /**
+ * Determine whether the user can manage settings.
+ */
+ public function manageSettings(User $user, Project $project): bool
+ {
+ return $this->hasRole($user, $project, ['owner', 'admin']);
+ }
+
+ /**
+ * Check if user has one of the specified roles for the project
+ */
+ private function hasRole(User $user, Project $project, array $roles): bool
+ {
+ $userRole = $project->users()
+ ->where('users.id', $user->id)
+ ->first()
+ ?->pivot
+ ?->role;
+
+ return $userRole && in_array($userRole, $roles);
+ }
+}
diff --git a/database/migrations/2026_03_21_134803_create_project_invitations_table.php b/database/migrations/2026_03_21_134803_create_project_invitations_table.php
new file mode 100644
index 0000000..022f6bc
--- /dev/null
+++ b/database/migrations/2026_03_21_134803_create_project_invitations_table.php
@@ -0,0 +1,36 @@
+id();
+ $table->foreignUlid('project_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('invited_by')->constrained('users')->cascadeOnDelete();
+ $table->string('email');
+ $table->string('role')->default('member'); // member, admin, owner
+ $table->string('token')->unique();
+ $table->timestamp('accepted_at')->nullable();
+ $table->timestamp('expires_at');
+ $table->timestamps();
+
+ $table->index(['email', 'project_id']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('project_invitations');
+ }
+};
diff --git a/resources/views/emails/project-invitation.blade.php b/resources/views/emails/project-invitation.blade.php
new file mode 100644
index 0000000..f99a688
--- /dev/null
+++ b/resources/views/emails/project-invitation.blade.php
@@ -0,0 +1,16 @@
+
+ Member: Can view issues and events.
+ Admin: Can also manage team members and project settings.
+
+{{ config('app.name') }}
+{{ $project->name }} — Team Members
+
+ @foreach($errors->all() as $error)
+
+ Invite Team Member
+
+ Pending Invitations
+ Team Members ({{ $members->count() }})
+ Permission Levels
+
+
+