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 @@ + +# You've been invited! + +{{ $invitation->inviter->name }} has invited you to join the **{{ $invitation->project->name }}** project as a **{{ ucfirst($invitation->role) }}**. + + +Accept Invitation + + +This invitation will expire {{ $invitation->expires_at->diffForHumans() }}. + +If you didn't expect this invitation, you can safely ignore this email. + +Thanks,
+{{ config('app.name') }} +
diff --git a/resources/views/layouts/navigation.blade.php b/resources/views/layouts/navigation.blade.php index 4303dd8..fb7a3a9 100644 --- a/resources/views/layouts/navigation.blade.php +++ b/resources/views/layouts/navigation.blade.php @@ -24,6 +24,9 @@ Alerts + + Team + Settings diff --git a/resources/views/projects/members/index.blade.php b/resources/views/projects/members/index.blade.php new file mode 100644 index 0000000..005d2ab --- /dev/null +++ b/resources/views/projects/members/index.blade.php @@ -0,0 +1,189 @@ + + @php($currentProject = $project) + + +

{{ $project->name }} — Team Members

+
+ +
+
+ + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + + @if($errors->any()) +
+
    + @foreach($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + {{-- Invite Member --}} + @can('manageMembers', $project) +
+

Invite Team Member

+
+ @csrf +
+ +
+
+ +
+ Send Invite +
+

+ Member: Can view issues and events. + Admin: Can also manage team members and project settings. +

+
+ @endcan + + {{-- Pending Invitations --}} + @if($pendingInvitations->isNotEmpty()) +
+
+

Pending Invitations

+
+
+ @foreach($pendingInvitations as $invitation) +
+
+
{{ $invitation->email }}
+
+ Invited by {{ $invitation->inviter->name }} + · {{ $invitation->created_at->diffForHumans() }} + · Expires {{ $invitation->expires_at->diffForHumans() }} +
+
+
+ + {{ ucfirst($invitation->role) }} + + @can('manageMembers', $project) +
+ @csrf + @method('DELETE') + +
+ @endcan +
+
+ @endforeach +
+
+ @endif + + {{-- Current Members --}} +
+
+

Team Members ({{ $members->count() }})

+
+
+ @foreach($members as $member) +
+
+
+ {{ $member->name }} + @if($member->id === auth()->id()) + (you) + @endif +
+
{{ $member->email }}
+
+ Joined {{ $member->pivot->created_at->diffForHumans() }} +
+
+
+ @can('manageMembers', $project) + @if($member->id !== auth()->id()) +
+ @csrf + @method('PATCH') + +
+ @else + + {{ ucfirst($member->pivot->role) }} + + @endif + @else + + {{ ucfirst($member->pivot->role) }} + + @endcan + + @can('manageMembers', $project) + @if($member->id !== auth()->id()) +
+ @csrf + @method('DELETE') + +
+ @endif + @endcan +
+
+ @endforeach +
+
+ + {{-- Role Descriptions --}} +
+

Permission Levels

+
+
Owner
+
Full access including project deletion
+ +
Admin
+
Manage team members, project settings, and all issues
+ +
Member
+
View and manage issues and events
+
+
+ +
+
+
diff --git a/resources/views/projects/settings.blade.php b/resources/views/projects/settings.blade.php index 43f117e..c8e8aab 100644 --- a/resources/views/projects/settings.blade.php +++ b/resources/views/projects/settings.blade.php @@ -36,9 +36,10 @@ {{-- Update Project --}} -
-

Project Details

-
+ @can('update', $project) +
+

Project Details

+ @csrf @method('PATCH') @@ -62,9 +63,11 @@ Save Changes
+ @endcan {{-- Delete Project --}} -
+ @can('delete', $project) +

Danger Zone

Deleting a project will permanently remove all its issues and events.

Delete Project
+ @endcan
diff --git a/routes/web.php b/routes/web.php index b56b259..2ca2549 100644 --- a/routes/web.php +++ b/routes/web.php @@ -7,12 +7,17 @@ use App\Http\Controllers\IssueController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProjectController; +use App\Http\Controllers\ProjectMemberController; use Illuminate\Support\Facades\Route; Route::get('/', function () { return view('welcome'); }); +Route::middleware('auth')->group(function () { + Route::get('/invitations/{token}/accept', [ProjectMemberController::class, 'acceptInvitation'])->name('invitations.accept'); +}); + Route::middleware(['auth', 'verified'])->group(function () { Route::get('/dashboard', DashboardController::class)->name('dashboard'); @@ -35,6 +40,12 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::patch('/projects/{project}/alerts', [AlertRuleController::class, 'update'])->name('projects.alerts.update'); Route::patch('/projects/{project}/alerts/preference', [AlertRuleController::class, 'updatePreference'])->name('projects.alerts.preference'); + Route::get('/projects/{project}/members', [ProjectMemberController::class, 'index'])->name('projects.members.index'); + Route::post('/projects/{project}/members/invite', [ProjectMemberController::class, 'invite'])->name('projects.members.invite'); + Route::patch('/projects/{project}/members/{user}/role', [ProjectMemberController::class, 'updateRole'])->name('projects.members.update-role'); + Route::delete('/projects/{project}/members/{user}', [ProjectMemberController::class, 'destroy'])->name('projects.members.destroy'); + Route::delete('/projects/{project}/invitations/{invitation}', [ProjectMemberController::class, 'cancelInvitation'])->name('projects.members.cancel-invitation'); + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); Route::get('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.read'); Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read');