From 06cd53fe917bd458bdc90975a6526ece14dc8fa6 Mon Sep 17 00:00:00 2001 From: Daan Meijer Date: Tue, 16 Jun 2026 16:29:17 +0200 Subject: [PATCH] added invitations --- app/Http/Controllers/DynamicController.php | 10 +- .../DynamicInvitationController.php | 112 ++++++++++++++++++ app/Mail/DynamicInvitationMail.php | 42 +++++++ app/Models/Dynamic.php | 5 + app/Models/DynamicInvitation.php | 31 +++++ ...23640_create_dynamic_invitations_table.php | 23 ++++ resources/css/components.css | 1 + resources/css/components/invite-form.css | 67 +++++++++++ resources/js/pages/Dynamics/Show.vue | 98 ++++++++++++++- .../emails/dynamics/invitation.blade.php | 18 +++ routes/web.php | 6 + tests/Feature/InvitationTest.php | 103 ++++++++++++++++ 12 files changed, 509 insertions(+), 7 deletions(-) create mode 100644 app/Http/Controllers/DynamicInvitationController.php create mode 100644 app/Mail/DynamicInvitationMail.php create mode 100644 app/Models/DynamicInvitation.php create mode 100644 database/migrations/2026_06_15_223640_create_dynamic_invitations_table.php create mode 100644 resources/css/components/invite-form.css create mode 100644 resources/views/emails/dynamics/invitation.blade.php create mode 100644 tests/Feature/InvitationTest.php diff --git a/app/Http/Controllers/DynamicController.php b/app/Http/Controllers/DynamicController.php index d3098eb..b417841 100644 --- a/app/Http/Controllers/DynamicController.php +++ b/app/Http/Controllers/DynamicController.php @@ -46,11 +46,11 @@ class DynamicController extends Controller /** * Display the specified resource. */ - public function show(Dynamic $dynamic, ActivityService $activityService) + public function show(Request $request, Dynamic $dynamic, ActivityService $activityService) { $this->authorize('view', $dynamic); - $activityService->updateCursor(auth()->user(), $dynamic); + $activityService->updateCursor($request->user(), $dynamic); $dynamic->load([ 'ledgers.media', @@ -59,8 +59,14 @@ class DynamicController extends Controller 'chat.messages.media' ]); + $isOwner = $dynamic->participants() + ->where('user_id', $request->user()->id) + ->where('role', 'owner') + ->exists(); + return Inertia::render('Dynamics/Show', [ 'dynamic' => $dynamic, + 'isOwner' => $isOwner, ]); } diff --git a/app/Http/Controllers/DynamicInvitationController.php b/app/Http/Controllers/DynamicInvitationController.php new file mode 100644 index 0000000..2253972 --- /dev/null +++ b/app/Http/Controllers/DynamicInvitationController.php @@ -0,0 +1,112 @@ +participants() + ->where('user_id', $request->user()->id) + ->where('role', 'owner') + ->exists(); + + if (!$isOwner) { + abort(403, 'Only dynamic owners can invite other users.'); + } + + // 2. Validate + $request->validate([ + 'email' => ['required', 'email'], + 'role' => ['required', 'string', 'in:owner,participant,editor,viewer'], + ]); + + $email = $request->input('email'); + $role = $request->input('role'); + + // Check if user is already a participant of this dynamic + $isParticipant = $dynamic->participants()->where('email', $email)->exists(); + if ($isParticipant) { + return redirect()->back()->withErrors([ + 'email' => 'This user is already a participant of this dynamic.', + ]); + } + + // Check if there is an active pending invitation for this user + $hasPendingInvite = $dynamic->invitations() + ->where('email', $email) + ->where('expires_at', '>', now()) + ->exists(); + + if ($hasPendingInvite) { + return redirect()->back()->withErrors([ + 'email' => 'An active invitation is already pending for this email address.', + ]); + } + + // 3. Create Invitation + $invitation = $dynamic->invitations()->create([ + 'email' => $email, + 'role' => $role, + 'token' => Str::random(40), + 'expires_at' => now()->addDays(7), + ]); + + // 4. Send Email + Mail::to($email)->send(new DynamicInvitationMail($invitation, $request->user()->name)); + + return redirect()->back()->with('success', 'Invitation successfully sent!'); + } + + /** + * Accept the specified invitation. + */ + public function accept(Request $request, string $token) + { + // Must be signed! + if (!$request->hasValidSignature()) { + abort(401, 'Invalid or expired signature.'); + } + + $invitation = DynamicInvitation::where('token', $token)->firstOrFail(); + + if ($invitation->isExpired()) { + abort(403, 'This invitation has expired.'); + } + + // Ensure the logged in user's email matches the invitation's email! + // "Only the user with the specified email address should be able to access the link." + if ($request->user()->email !== $invitation->email) { + abort(403, 'This invitation was sent to a different email address.'); + } + + DB::transaction(function () use ($request, $invitation) { + // Attach user to dynamic as a participant with the specified role + $dynamic = $invitation->dynamic; + $dynamic->participants()->attach($request->user()->id, ['role' => $invitation->role]); + + // Log to Dynamic chat activity log! + $dynamic->chat->messages()->create([ + 'user_id' => $request->user()->id, + 'content' => "System: {$request->user()->name} joined the Dynamic as a " . strtoupper($invitation->role) . " after accepting an invitation.", + ]); + + // Delete the invitation record + $invitation->delete(); + }); + + return redirect()->route('dynamics.show', $invitation->dynamic_id)->with('success', 'Successfully joined the dynamic!'); + } +} diff --git a/app/Mail/DynamicInvitationMail.php b/app/Mail/DynamicInvitationMail.php new file mode 100644 index 0000000..135165e --- /dev/null +++ b/app/Mail/DynamicInvitationMail.php @@ -0,0 +1,42 @@ +invitation->dynamic->name, + ); + } + + public function content(): Content { + $acceptUrl = URL::temporarySignedRoute( + 'dynamics.invitations.accept', + $this->invitation->expires_at, + ['token' => $this->invitation->token] + ); + + return new Content( + markdown: 'emails.dynamics.invitation', + with: [ + 'acceptUrl' => $acceptUrl, + 'dynamicName' => $this->invitation->dynamic->name, + 'role' => $this->invitation->role, + ], + ); + } +} diff --git a/app/Models/Dynamic.php b/app/Models/Dynamic.php index 957471b..cebac43 100644 --- a/app/Models/Dynamic.php +++ b/app/Models/Dynamic.php @@ -29,6 +29,11 @@ class Dynamic extends Model return $this->hasMany(Ledger::class); } + public function invitations(): HasMany + { + return $this->hasMany(DynamicInvitation::class); + } + public function chat(): MorphOne { return $this->morphOne(Chat::class, 'chatable'); diff --git a/app/Models/DynamicInvitation.php b/app/Models/DynamicInvitation.php new file mode 100644 index 0000000..1c46647 --- /dev/null +++ b/app/Models/DynamicInvitation.php @@ -0,0 +1,31 @@ + 'datetime', + ]; + + public function dynamic(): BelongsTo { + return $this->belongsTo(Dynamic::class); + } + + public function isExpired(): bool { + return $this->expires_at->isPast(); + } +} diff --git a/database/migrations/2026_06_15_223640_create_dynamic_invitations_table.php b/database/migrations/2026_06_15_223640_create_dynamic_invitations_table.php new file mode 100644 index 0000000..f139925 --- /dev/null +++ b/database/migrations/2026_06_15_223640_create_dynamic_invitations_table.php @@ -0,0 +1,23 @@ +id(); + $table->foreignId('dynamic_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('role'); + $table->string('token')->unique(); + $table->timestamp('expires_at'); + $table->timestamps(); + }); + } + + public function down(): void { + Schema::dropIfExists('dynamic_invitations'); + } +}; diff --git a/resources/css/components.css b/resources/css/components.css index a6906da..5e63a2c 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -14,3 +14,4 @@ @import './components/auth-layout.css'; @import './components/chat.css'; @import './components/lightbox.css'; +@import './components/invite-form.css'; diff --git a/resources/css/components/invite-form.css b/resources/css/components/invite-form.css new file mode 100644 index 0000000..7ca7ed1 --- /dev/null +++ b/resources/css/components/invite-form.css @@ -0,0 +1,67 @@ +/* 13. InviteForm Component */ +.c-invite-form { + @apply mt-8; + + .c-invite-form__card { + @apply overflow-hidden; + background-color: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + + .c-invite-form__body { + @apply p-6; + color: var(--foreground); + + .c-invite-form__title { + @apply text-lg font-medium; + } + + .c-invite-form__form { + @apply mt-6 space-y-6; + + .c-invite-form__field { + @apply block; + + .c-invite-form__label { + @apply block text-sm font-medium; + color: var(--foreground); + } + + .c-invite-form__input { + @apply mt-1 block w-full rounded-md border shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600; + border-color: var(--border); + background-color: var(--background); + color: var(--foreground); + } + + .c-invite-form__select { + @apply mt-1 block w-full rounded-md border p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600; + border-color: var(--border); + background-color: var(--background); + color: var(--foreground); + } + + .c-invite-form__error { + @apply text-sm; + color: var(--destructive); + } + } + + .c-invite-form__actions { + @apply flex items-center gap-4; + + .c-invite-form__submit-btn { + @apply inline-flex items-center border border-transparent px-4 py-2 text-xs font-semibold tracking-widest uppercase transition duration-150 ease-in-out focus:ring-2 focus:outline-none; + border-radius: var(--radius); + background-color: var(--primary); + color: var(--primary-foreground); + + &:hover { + opacity: 0.9; + } + } + } + } + } + } +} diff --git a/resources/js/pages/Dynamics/Show.vue b/resources/js/pages/Dynamics/Show.vue index 12d6531..85dafb5 100644 --- a/resources/js/pages/Dynamics/Show.vue +++ b/resources/js/pages/Dynamics/Show.vue @@ -1,10 +1,10 @@ @@ -92,6 +179,7 @@ const breadcrumbs = [ } .c-dynamic-show__rules { - @apply mt-2 text-sm text-gray-600 dark:text-gray-400; + @apply mt-2 text-sm; + color: var(--muted-foreground); } diff --git a/resources/views/emails/dynamics/invitation.blade.php b/resources/views/emails/dynamics/invitation.blade.php new file mode 100644 index 0000000..52f07bd --- /dev/null +++ b/resources/views/emails/dynamics/invitation.blade.php @@ -0,0 +1,18 @@ + +# You have been invited! + +Hello, + +**{{ $inviterName }}** has invited you to join their Dynamic: **{{ $dynamicName }}** as a **{{ strtoupper($role) }}**. + +Only the user registered with this email address can accept this invitation. The invitation link will be valid for 7 days. + + +Accept Invitation + + +If you do not have an account yet, please register using this email address first, then click the button above. + +Thanks,
+{{ config('app.name') }} +
diff --git a/routes/web.php b/routes/web.php index 49a81ef..a0d23a6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,8 +16,14 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::resource('dynamics.ledgers', LedgerController::class)->scoped(); Route::resource('dynamics.ledgers.mutations', MutationController::class)->scoped(); + Route::post('/dynamics/{dynamic}/invitations', [\App\Http\Controllers\DynamicInvitationController::class, 'store'])->name('dynamics.invitations.store'); + Route::post('/chats/{chat}/messages', [MessageController::class, 'store'])->name('chats.messages.store'); }); +Route::get('/invitations/accept/{token}', [\App\Http\Controllers\DynamicInvitationController::class, 'accept']) + ->middleware(['auth', 'signed']) + ->name('dynamics.invitations.accept'); + \Illuminate\Support\Facades\Broadcast::routes(); require __DIR__.'/settings.php'; diff --git a/tests/Feature/InvitationTest.php b/tests/Feature/InvitationTest.php new file mode 100644 index 0000000..fe9e7b9 --- /dev/null +++ b/tests/Feature/InvitationTest.php @@ -0,0 +1,103 @@ +create(); + $participant = User::factory()->create(); + $dynamic = Dynamic::factory()->create(); + + $dynamic->participants()->attach($owner->id, ['role' => 'owner']); + $dynamic->participants()->attach($participant->id, ['role' => 'participant']); + + // 1. Participant tries to send an invite (forbidden) + $response = $this->actingAs($participant) + ->post(route('dynamics.invitations.store', $dynamic), [ + 'email' => 'invitee@example.com', + 'role' => 'participant', + ]); + + $response->assertStatus(403); + Mail::assertNothingSent(); + + // 2. Owner sends a valid invite (allowed) + $response = $this->actingAs($owner) + ->post(route('dynamics.invitations.store', $dynamic), [ + 'email' => 'invitee@example.com', + 'role' => 'participant', + ]); + + $response->assertRedirect(); + $response->assertSessionHasNoErrors(); + + // Verify invitation is stored + $invitation = DynamicInvitation::firstWhere('email', 'invitee@example.com'); + expect($invitation)->not->toBeNull(); + expect($invitation->role)->toBe('participant'); + expect($invitation->dynamic_id)->toBe($dynamic->id); + + // Verify email was dispatched + Mail::assertSent(DynamicInvitationMail::class, function ($mail) use ($invitation) { + return $mail->hasTo($invitation->email); + }); +}); + +test('only the user with the specified email address can accept the link', function () { + $owner = User::factory()->create(); + $invitee = User::factory()->create(['email' => 'matching@example.com']); + $hijacker = User::factory()->create(['email' => 'hijacker@example.com']); + $dynamic = Dynamic::factory()->create(); + + $dynamic->participants()->attach($owner->id, ['role' => 'owner']); + + // Create a pending invitation + $invitation = $dynamic->invitations()->create([ + 'email' => 'matching@example.com', + 'role' => 'editor', + 'token' => 'secure_test_token_123', + 'expires_at' => now()->addDays(7), + ]); + + // Generate a secure, valid signed URL + $signedUrl = URL::temporarySignedRoute( + 'dynamics.invitations.accept', + $invitation->expires_at, + ['token' => $invitation->token] + ); + + // 1. Unauthenticated user tries to accept (redirected to login) + $response = $this->get($signedUrl); + $response->assertRedirect('/login'); + + // 2. Different user (hijacker) tries to accept (forbidden / 403) + $response = $this->actingAs($hijacker)->get($signedUrl); + $response->assertStatus(403); + expect($dynamic->participants()->where('user_id', $hijacker->id)->exists())->toBeFalse(); + + // 3. Intended user accepts the signed invitation link (success) + $response = $this->actingAs($invitee)->get($signedUrl); + $response->assertRedirect(route('dynamics.show', $dynamic)); + + // Verify invitee is joined as a participant with the specified role + $isJoined = $dynamic->participants() + ->where('user_id', $invitee->id) + ->wherePivot('role', 'editor') + ->exists(); + + expect($isJoined)->toBeTrue(); + + // Verify invitation is deleted from the database + expect(DynamicInvitation::where('token', 'secure_test_token_123')->exists())->toBeFalse(); + + // Verify system notification is added to Dynamic activity chat + $chatMessages = $dynamic->chat->messages; + expect($chatMessages)->not->toBeEmpty(); + expect($chatMessages->last()->content)->toBe("System: {$invitee->name} joined the Dynamic as a EDITOR after accepting an invitation."); +});