diff --git a/app/Http/Controllers/Web/OrganizationInvitationController.php b/app/Http/Controllers/Web/OrganizationInvitationController.php index 63148b1c..d0fd3606 100644 --- a/app/Http/Controllers/Web/OrganizationInvitationController.php +++ b/app/Http/Controllers/Web/OrganizationInvitationController.php @@ -9,6 +9,7 @@ use App\Models\OrganizationInvitation; use App\Models\User; use App\Service\MemberService; use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Auth; use RuntimeException; class OrganizationInvitationController extends Controller @@ -21,44 +22,54 @@ class OrganizationInvitationController extends Controller throw new RuntimeException('Invalid role'); } - $newOrganizationMember = User::query() + $organization = $invitation->organization; + $invitee = User::query() ->where('email', $email) ->where('is_placeholder', '=', false) ->first(); - if ($newOrganizationMember === null) { + // No account yet — finish on registration. + if ($invitee === null) { if ($invitation->accepted_at === null) { $invitation->accepted_at = now(); $invitation->save(); } - return redirect(route('register', [ - 'bannerStyle' => 'info', - 'bannerText' => __('Please create an account to finish joining the :organization organization.', [ - 'organization' => $invitation->organization->name, - ]), - ])); - } else { - $organization = $invitation->organization; - if ($memberService->isEmailAlreadyMember($organization, $email)) { - return redirect(route('dashboard', [ - 'bannerStyle' => 'danger', - 'bannerText' => __('You are already a member of the :organization organization.', [ - 'organization' => $organization->name, - ]), - ])); - } - - $memberService->addMember($newOrganizationMember, $organization, $role); - - $invitation->delete(); - - return redirect(route('dashboard', [ - 'bannerStyle' => 'success', - 'bannerText' => __('Great! You have accepted the invitation to join the :organization organization.', [ - 'organization' => $invitation->organization->name, - ]), - ])); + return redirect(route('register')) + ->with('bannerText', __('Please create an account to finish joining the :organization organization.', [ + 'organization' => $organization->name, + ])) + ->with('bannerStyle', 'info'); } + + $alreadyMember = $memberService->isEmailAlreadyMember($organization, $email); + if (! $alreadyMember) { + $memberService->addMember($invitee, $organization, $role); + $invitation->delete(); + } + + // Logged out — banner on /login. + if (! Auth::check()) { + return redirect(route('login')) + ->with('bannerText', __('Please log in to finish joining the :organization organization.', [ + 'organization' => $organization->name, + ])) + ->with('bannerStyle', 'info'); + } + + // Logged in — banner on /dashboard. + if ($alreadyMember) { + return redirect(route('dashboard')) + ->with('bannerText', __('You are already a member of the :organization organization.', [ + 'organization' => $organization->name, + ])) + ->with('bannerStyle', 'danger'); + } + + return redirect(route('dashboard')) + ->with('bannerText', __('Great! You have accepted the invitation to join the :organization organization.', [ + 'organization' => $organization->name, + ])) + ->with('bannerStyle', 'success'); } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 0c2f1265..0baae6f4 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -60,6 +60,8 @@ class HandleInertiaRequests extends Middleware ] : null, 'flash' => [ 'message' => fn () => $request->session()->get('message'), + 'bannerText' => fn () => $request->session()->get('bannerText'), + 'bannerStyle' => fn () => $request->session()->get('bannerStyle'), ], ]); } diff --git a/app/Http/Middleware/ShareInertiaData.php b/app/Http/Middleware/ShareInertiaData.php index b37a5c5a..a3b3ce2f 100644 --- a/app/Http/Middleware/ShareInertiaData.php +++ b/app/Http/Middleware/ShareInertiaData.php @@ -39,7 +39,6 @@ class ShareInertiaData 'canUpdatePassword' => Features::enabled(Features::updatePasswords()), 'canUpdateProfileInformation' => Features::canUpdateProfileInformation(), 'hasEmailVerification' => Features::enabled(Features::emailVerification()), - 'flash' => $request->session()->get('flash', []), 'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(), 'hasApiFeatures' => Jetstream::hasApiFeatures(), 'hasTeamFeatures' => Jetstream::hasTeamFeatures(), diff --git a/e2e/invitation-accept.spec.ts b/e2e/invitation-accept.spec.ts new file mode 100644 index 00000000..55527de5 --- /dev/null +++ b/e2e/invitation-accept.spec.ts @@ -0,0 +1,156 @@ +import { expect, test } from '../playwright/fixtures'; +import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config'; +import { getInvitationAcceptUrl } from './utils/mailpit'; +import { registerUser } from './utils/members'; + +// Invitation acceptance flows touch mail delivery + redirects. +test.describe.configure({ timeout: 45000 }); + +test.describe('invitation accept banners', () => { + test('shows success banner on dashboard when a logged-in registered user accepts an invitation', async ({ + page, + browser, + }) => { + const memberId = Math.floor(Math.random() * 100000); + const memberEmail = `success+${memberId}@invite-banner.test`; + + // Invitee already has an account and is logged in. + const invitee = await registerUser(browser, 'Banner Success', memberEmail); + + // Owner sends the invitation. + await page.goto(PLAYWRIGHT_BASE_URL + '/members'); + await page.getByRole('button', { name: 'Invite Member' }).click(); + await expect(page.getByPlaceholder('Member Email')).toBeVisible(); + await page.getByLabel('Email').fill(memberEmail); + await page.getByRole('button', { name: 'Employee' }).click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/invitations') && + response.request().method() === 'POST' && + response.status() === 204 + ), + page.getByRole('button', { name: 'Invite Member', exact: true }).click(), + ]); + + // Invitee clicks the email link. + const acceptUrl = await getInvitationAcceptUrl(invitee.page.request, memberEmail); + await invitee.page.goto(acceptUrl); + await invitee.page.waitForURL(/\/dashboard$/); + + const banner = invitee.page.getByTestId('banner'); + await expect(banner).toBeVisible(); + await expect(banner).toContainText( + /Great! You have accepted the invitation to join the .* organization\./ + ); + + await invitee.close(); + }); + + test('shows info banner on login screen when a registered-but-logged-out invitee clicks the accept link', async ({ + page, + browser, + }) => { + const memberId = Math.floor(Math.random() * 100000); + const memberEmail = `loggedout+${memberId}@invite-banner.test`; + + // Invitee has an account, but the context that clicks the link has no session. + const invitee = await registerUser(browser, 'Banner Loggedout', memberEmail); + await invitee.close(); + + // Owner sends the invitation. + await page.goto(PLAYWRIGHT_BASE_URL + '/members'); + await page.getByRole('button', { name: 'Invite Member' }).click(); + await expect(page.getByPlaceholder('Member Email')).toBeVisible(); + await page.getByLabel('Email').fill(memberEmail); + await page.getByRole('button', { name: 'Employee' }).click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/invitations') && + response.request().method() === 'POST' && + response.status() === 204 + ), + page.getByRole('button', { name: 'Invite Member', exact: true }).click(), + ]); + + // Open the accept link in a fresh browser context (no session). + const context = await browser.newContext(); + const inviteePage = await context.newPage(); + const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail); + await inviteePage.goto(acceptUrl); + await inviteePage.waitForURL(/\/login$/); + + const banner = inviteePage.getByTestId('banner'); + await expect(banner).toBeVisible(); + await expect(banner).toContainText(/Please log in to finish joining the .* organization\./); + + // Logging in lands the invitee on the dashboard — they were already added silently + // by the accept controller, so the inviter's members list shows them. + await inviteePage.getByLabel('Email').fill(memberEmail); + await inviteePage.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD); + await inviteePage.getByRole('button', { name: 'Log in' }).click(); + await inviteePage.waitForURL(/\/dashboard/); + + await page.goto(PLAYWRIGHT_BASE_URL + '/members'); + const memberRow = page.getByRole('row').filter({ hasText: 'Banner Loggedout' }); + await expect(memberRow).toBeVisible(); + await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible(); + + await context.close(); + }); + + test('shows info banner on register screen when an unregistered email accepts an invitation, then auto-joins on registration', async ({ + page, + browser, + }) => { + const memberId = Math.floor(Math.random() * 100000); + const memberEmail = `info+${memberId}@invite-banner.test`; + + // Owner invites an email that has no account yet. + await page.goto(PLAYWRIGHT_BASE_URL + '/members'); + await page.getByRole('button', { name: 'Invite Member' }).click(); + await expect(page.getByPlaceholder('Member Email')).toBeVisible(); + await page.getByLabel('Email').fill(memberEmail); + await page.getByRole('button', { name: 'Employee' }).click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/invitations') && + response.request().method() === 'POST' && + response.status() === 204 + ), + page.getByRole('button', { name: 'Invite Member', exact: true }).click(), + ]); + + // Open the accept link in a fresh browser context (no session). + const context = await browser.newContext(); + const inviteePage = await context.newPage(); + const acceptUrl = await getInvitationAcceptUrl(inviteePage.request, memberEmail); + await inviteePage.goto(acceptUrl); + await inviteePage.waitForURL(/\/register$/); + + const banner = inviteePage.getByTestId('banner'); + await expect(banner).toBeVisible(); + await expect(banner).toContainText( + /Please create an account to finish joining the .* organization\./ + ); + + // Complete registration — the invitee should auto-join the inviter's org + // (no fresh personal organization is created on top). + await inviteePage.getByLabel('Name').fill('Banner Info'); + await inviteePage.getByLabel('Email').fill(memberEmail); + await inviteePage.getByLabel('Password', { exact: true }).fill(TEST_USER_PASSWORD); + await inviteePage.getByLabel('Confirm Password').fill(TEST_USER_PASSWORD); + await inviteePage.getByLabel('I agree to the Terms of').click(); + await inviteePage.getByRole('button', { name: 'Register' }).click(); + await inviteePage.waitForURL(/\/dashboard/); + + await page.goto(PLAYWRIGHT_BASE_URL + '/members'); + const memberRow = page.getByRole('row').filter({ hasText: 'Banner Info' }); + await expect(memberRow).toBeVisible(); + await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible(); + + await context.close(); + }); +}); diff --git a/e2e/utils/mailpit.ts b/e2e/utils/mailpit.ts index 8d5c807a..bfdd007f 100644 --- a/e2e/utils/mailpit.ts +++ b/e2e/utils/mailpit.ts @@ -46,7 +46,9 @@ export async function getInvitationAcceptUrl( expect(searchResult.messages.length).toBeGreaterThan(0); const message = await getMessage(request, searchResult.messages[0].ID); - const acceptUrlMatch = message.HTML.match(/href="([^"]*team-invitations[^"]*)"/); + const acceptUrlMatch = message.HTML.match( + /href="([^"]*(?:organization-invitations|team-invitations)[^"]*)"/ + ); expect(acceptUrlMatch).toBeTruthy(); return acceptUrlMatch![1].replace(/&/g, '&'); diff --git a/resources/js/Components/Banner.vue b/resources/js/Components/Banner.vue index 475f0eae..efb38c5d 100644 --- a/resources/js/Components/Banner.vue +++ b/resources/js/Components/Banner.vue @@ -1,36 +1,38 @@