team members and invitations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('project_invitations', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
<x-mail::message>
|
||||
# You've been invited!
|
||||
|
||||
{{ $invitation->inviter->name }} has invited you to join the **{{ $invitation->project->name }}** project as a **{{ ucfirst($invitation->role) }}**.
|
||||
|
||||
<x-mail::button :url="route('invitations.accept', $invitation->token)">
|
||||
Accept Invitation
|
||||
</x-mail::button>
|
||||
|
||||
This invitation will expire {{ $invitation->expires_at->diffForHumans() }}.
|
||||
|
||||
If you didn't expect this invitation, you can safely ignore this email.
|
||||
|
||||
Thanks,<br>
|
||||
{{ config('app.name') }}
|
||||
</x-mail::message>
|
||||
@@ -24,6 +24,9 @@
|
||||
<x-nav-link :href="route('projects.alerts.edit', $currentProject)" :active="request()->routeIs('projects.alerts.*')">
|
||||
Alerts
|
||||
</x-nav-link>
|
||||
<x-nav-link :href="route('projects.members.index', $currentProject)" :active="request()->routeIs('projects.members.*')">
|
||||
Team
|
||||
</x-nav-link>
|
||||
<x-nav-link :href="route('projects.settings', $currentProject)" :active="request()->routeIs('projects.settings')">
|
||||
Settings
|
||||
</x-nav-link>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<x-app-layout>
|
||||
@php($currentProject = $project)
|
||||
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">{{ $project->name }} — Team Members</h2>
|
||||
</x-slot>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
|
||||
@if(session('success'))
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-md text-sm">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($errors->any())
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm">
|
||||
<ul class="list-disc list-inside">
|
||||
@foreach($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Invite Member --}}
|
||||
@can('manageMembers', $project)
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Invite Team Member</h3>
|
||||
<form method="POST" action="{{ route('projects.members.invite', $project) }}" class="flex gap-3">
|
||||
@csrf
|
||||
<div class="flex-1">
|
||||
<x-text-input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
class="w-full"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<select
|
||||
id="role"
|
||||
name="role"
|
||||
class="w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
required
|
||||
>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<x-primary-button>Send Invite</x-primary-button>
|
||||
</form>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
<strong>Member:</strong> Can view issues and events.
|
||||
<strong>Admin:</strong> Can also manage team members and project settings.
|
||||
</p>
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
{{-- Pending Invitations --}}
|
||||
@if($pendingInvitations->isNotEmpty())
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Pending Invitations</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200">
|
||||
@foreach($pendingInvitations as $invitation)
|
||||
<div class="p-4 flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900">{{ $invitation->email }}</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
Invited by {{ $invitation->inviter->name }}
|
||||
· {{ $invitation->created_at->diffForHumans() }}
|
||||
· Expires {{ $invitation->expires_at->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{{ ucfirst($invitation->role) }}
|
||||
</span>
|
||||
@can('manageMembers', $project)
|
||||
<form method="POST" action="{{ route('projects.members.cancel-invitation', [$project, $invitation]) }}" class="inline">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button
|
||||
type="submit"
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
onclick="return confirm('Cancel this invitation?')"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Current Members --}}
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Team Members ({{ $members->count() }})</h3>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200">
|
||||
@foreach($members as $member)
|
||||
<div class="p-4 flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-gray-900">
|
||||
{{ $member->name }}
|
||||
@if($member->id === auth()->id())
|
||||
<span class="text-sm text-gray-500">(you)</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">{{ $member->email }}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
Joined {{ $member->pivot->created_at->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@can('manageMembers', $project)
|
||||
@if($member->id !== auth()->id())
|
||||
<form method="POST" action="{{ route('projects.members.update-role', [$project, $member]) }}" class="inline">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<select
|
||||
name="role"
|
||||
class="text-sm border-gray-300 rounded-md"
|
||||
onchange="this.form.submit()"
|
||||
>
|
||||
<option value="member" {{ $member->pivot->role === 'member' ? 'selected' : '' }}>Member</option>
|
||||
<option value="admin" {{ $member->pivot->role === 'admin' ? 'selected' : '' }}>Admin</option>
|
||||
<option value="owner" {{ $member->pivot->role === 'owner' ? 'selected' : '' }}>Owner</option>
|
||||
</select>
|
||||
</form>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{{ ucfirst($member->pivot->role) }}
|
||||
</span>
|
||||
@endif
|
||||
@else
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{{ ucfirst($member->pivot->role) }}
|
||||
</span>
|
||||
@endcan
|
||||
|
||||
@can('manageMembers', $project)
|
||||
@if($member->id !== auth()->id())
|
||||
<form method="POST" action="{{ route('projects.members.destroy', [$project, $member]) }}" class="inline">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button
|
||||
type="submit"
|
||||
class="text-sm text-red-600 hover:text-red-800"
|
||||
onclick="return confirm('Remove {{ $member->name }} from this project?')"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Role Descriptions --}}
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h4 class="font-medium text-blue-900 mb-2">Permission Levels</h4>
|
||||
<dl class="text-sm space-y-1">
|
||||
<dt class="font-medium text-blue-800">Owner</dt>
|
||||
<dd class="text-blue-700 ml-4">Full access including project deletion</dd>
|
||||
|
||||
<dt class="font-medium text-blue-800 mt-2">Admin</dt>
|
||||
<dd class="text-blue-700 ml-4">Manage team members, project settings, and all issues</dd>
|
||||
|
||||
<dt class="font-medium text-blue-800 mt-2">Member</dt>
|
||||
<dd class="text-blue-700 ml-4">View and manage issues and events</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -36,6 +36,7 @@
|
||||
</div>
|
||||
|
||||
{{-- Update Project --}}
|
||||
@can('update', $project)
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Project Details</h3>
|
||||
<form method="POST" action="{{ route('projects.update', $project) }}" class="space-y-6">
|
||||
@@ -62,8 +63,10 @@
|
||||
<x-primary-button>Save Changes</x-primary-button>
|
||||
</form>
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
{{-- Delete Project --}}
|
||||
@can('delete', $project)
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
|
||||
<h3 class="text-lg font-medium text-red-600 mb-2">Danger Zone</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">Deleting a project will permanently remove all its issues and events.</p>
|
||||
@@ -74,6 +77,7 @@
|
||||
<x-danger-button>Delete Project</x-danger-button>
|
||||
</form>
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user