team members and invitations

This commit is contained in:
Doar Mihai
2026-03-21 16:13:38 +02:00
parent 756747cd00
commit add7169de3
15 changed files with 666 additions and 31 deletions
+6 -2
View File
@@ -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;
}
+4 -12
View File
@@ -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,
);
}
}
+4 -12
View File
@@ -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,
);
}
}
@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers;
use App\Mail\ProjectInvitationMail;
use App\Models\Project;
use App\Models\ProjectInvitation;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\View\View;
class ProjectMemberController extends Controller
{
/**
* Display team members for a project
*/
public function index(Project $project): View
{
$this->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.');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Mail;
use App\Models\ProjectInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ProjectInvitationMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public ProjectInvitation $invitation
) {
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: "You've been invited to join {$this->invitation->project->name}",
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.project-invitation',
);
}
}
+24
View File
@@ -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']);
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class ProjectInvitation extends Model
{
protected $fillable = [
'project_id',
'invited_by',
'email',
'role',
'token',
'expires_at',
'accepted_at',
];
protected $casts = [
'expires_at' => '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()]);
}
}
+80
View File
@@ -0,0 +1,80 @@
<?php
namespace App\Policies;
use App\Models\Project;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class ProjectPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Project $project): bool
{
return $project->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);
}
}